Compare commits

..
253 Commits
Author SHA1 Message Date
2dust bcfb9c2bc9 up 2.3.0 2026-08-01 14:42:09 +08:00
2dust 55103078c2 Update hev-socks5-tunnel 2026-08-01 14:28:40 +08:00
vemneyyand2dust 8b81fdf383 Fix tunnel hang on LTE -> WiFi handover (#5977)
* fix: restart the core on network change in all run modes

* Update CoreServiceManager.kt

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-08-01 14:20:23 +08:00
eliotcougar 17b6254d4d Fulfill foreground service notification contract (#5982)
Promote each foreground service before onStartCommand can return early. Reuse or create the core notification on repeated starts, and migrate its hidden channel to a silent low-importance channel with a new ID.
2026-08-01 10:26:21 +08:00
dependabot[bot] 435182e0c9 Bump actions/setup-java from 5 to 5.6.0 (#5983)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5 to 5.6.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v5.6.0)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.6.0
  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-08-01 10:16:52 +08:00
eliotcougar 257c4bf21b Stabilize profile editor IME scrolling (#5980)
Profile editors are bounded forms whose focused child must remain stable while the IME changes the available height. Lazy list item boundaries can be remeasured or detached during these viewport changes, causing visible jitter and leaving lower or multiline fields obscured.

Compose the form eagerly in one vertically scrollable Column. Preserve the existing spacing, scrollbar, IME padding, and bottom padding, and update only protocol-editor call sites to the ColumnScope API.
2026-08-01 10:16:14 +08:00
2dust 389e55d72a Code clean 2026-07-31 20:37:01 +08:00
2dust 490a07d75b Move message/notification utils to helper package 2026-07-31 20:27:08 +08:00
2dust d4ebab69c7 Move Compose package into ui namespace 2026-07-31 20:20:25 +08:00
2dust 97195f2d3a Split toast and string extensions 2026-07-31 20:13:06 +08:00
2dust be2dabe029 Move IDialerService to contracts package 2026-07-31 19:59:13 +08:00
2dust 3fc03bad2b Update AndroidLibXrayLite 2026-07-31 16:31:56 +08:00
2dust 888d7cd398 Update AndroidLibXrayLite 2026-07-31 15:38:25 +08:00
2dust 919dd71ea7 refactor: achieve complete process isolation for CoreServiceManager 2026-07-31 15:36:44 +08:00
2dust 2c1efde450 Bug fix 2026-07-31 10:54:16 +08:00
eliotcougarand2dust e549000e5a Use typed server menu actions and explicit delete dialogs (#5975)
* Use typed server actions and explicit dialogs

* Add ConfirmDialog

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-07-31 10:34:05 +08:00
eliotcougar aacc8e091f Preserve app picker state on Activity recreation (#5972)
* Preserve app picker selection on recreation

* Avoid reloading app picker data on recreation
2026-07-31 09:59:33 +08:00
eliotcougar 070abd78ea Use a dropdown for routing network (#5976) 2026-07-31 09:56:59 +08:00
eliotcougar 3380a45475 Deduplicate and mirror scrollbars in RTL (#5974) 2026-07-31 09:55:27 +08:00
eliotcougar 4fee3a67f7 Consistent reorderable list behavior and persistence (#5968)
* Fix reordered list behavior and persistence

Use remove-and-insert semantics for every reorderable collection and persist the resulting order instead of swapping endpoints.

Resolve routing rules and proxy-chain members by stable keys, make proxy rows draggable, and serialize profile-order writes per group so rapid drag updates cannot be persisted out of order.

* Signal touch reorder activation with haptic feedback

Emit one gesture-threshold pulse when a long press makes an item draggable. Reuse the same handle across every reorderable list and rely on the platform touch-feedback setting.
2026-07-31 09:52:26 +08:00
eliotcougarand2dust 794940a1a0 Make snackbar delivery lifecycle-safe (#5973)
* Make snackbar delivery lifecycle-safe

* Update SnackBar.kt

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-07-31 09:40:49 +08:00
2dust 8421d2300b Bug fix 2026-07-31 09:27:15 +08:00
2dust 596084b939 Bug fix
https://github.com/2dust/v2rayNG/issues/5961
2026-07-29 21:21:21 +08:00
2dust 35c39e4f36 Simplify browserDialer 2026-07-29 21:20:04 +08:00
2dust 4295d5d6d1 Warn on insecure nodes without pinned cert 2026-07-29 20:13:21 +08:00
2dust 827682b768 Update ProfileItem.kt 2026-07-29 20:12:17 +08:00
Alexandrand2dust 9896dd2974 fix: reset stuck start lock on system Always-on VPN restart (#5958)
* fix: reset stuck start lock on system Always-on VPN restart

When Android restarts CoreVpnService for Always-on VPN (via VpnService.SERVICE_INTERFACE
or null intent), a stuck isStartingLock from a killed process prevents tryLockStart() from
succeeding, causing an immediate return without setupVpnService().

Fix: Reset stuck start lock via unlockStart() on system-initiated VPN start/restart,
allowing setupVpnService() to establish the TUN interface. If setup fails during system
start, return START_STICKY to keep the service alive for OS reconnection.

Also accept LOCKED_BOOT_COMPLETED and MY_PACKAGE_REPLACED in BootReceiver.

* Update BootReceiver.kt

* Update CoreVpnService.kt

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-07-28 21:09:03 +08:00
Anupam Mediratta 842567fb89 fix: V-001 security vulnerability (#5951)
Automated security fix generated by OrbisAI Security
2026-07-27 20:31:52 +08:00
2dust cce2c9cdbd Adjust const 2026-07-24 14:37:02 +08:00
2dust 7f6bf57ce1 Update libs.versions.toml 2026-07-24 14:36:41 +08:00
2dust 5ee70b723b Add TCP ping mode for all-server testing 2026-07-24 11:12:23 +08:00
eliotcougarand2dust 2be47f37fe Random and Round-Robin strategies can exclude dead outbounds (#5926)
* Add policy group fallback health checks

* Refine policy group observatory defaults

* Simplify policy group fallback

* Localize policy group fallback settings

* Update CoreConfigContextBuilder.kt

* Update CoreConfigManager.kt

* Restore implicit policy group fallback

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-07-24 10:20:16 +08:00
eliotcougar ce75aad55e Make settings groups collapsible (#5939) 2026-07-23 20:59:55 +08:00
2dust c9e1501d39 Update Theme.kt SnackBar.kt MainImportMenu.kt 2026-07-23 17:12:23 +08:00
2dust 3be230c024 Make subscription config update optional 2026-07-23 16:34:12 +08:00
2dust 3ae13b0ba4 Remove the tested features from the main 2026-07-23 16:10:09 +08:00
2dust a231151e70 refactor: move subscription update logic to SubscriptionUpdateService
- Refactor subscription synchronization and testing from static object to a dedicated foreground service.
- Implement SubscriptionUpdateService with accurate lifecycle management using AtomicInteger (runningTasks).
- Enforce serial execution of subscription updates via Semaphore(1) to ensure data integrity and bandwidth control.
- Fix NetworkOnMainThreadException by switching service execution to Dispatchers.IO.
- Decouple update logic from Main UI components for better modularity.
- Prevent rapid rescheduling loops by updating 'lastUpdated' timestamp at the start of the task.
- Simplify messaging by supporting multiple subscription IDs in a single SubscriptionUpdateMessage.
2026-07-23 14:53:13 +08:00
2dust f7f170e8d3 Tiny fix 2026-07-22 20:53:04 +08:00
2dust 1b13fadce7 Improve Divider 2026-07-21 21:19:07 +08:00
fuilloi fc39e045fc Fix Snackbar (#5934)
* Fix snackbar positioned low when virtual navigation

* Fix checkupdate page not display updating information

* Fix first snackbar short in consecutive message

* fix snackbar the RESUMED show timing
2026-07-21 20:50:28 +08:00
2dust 60562acb80 Add LogcatViewModel copyLogcat 2026-07-21 20:48:34 +08:00
2dust 4949528eeb Code clean 2026-07-21 18:24:27 +08:00
2dust f6ea2df5a0 Refactor UI package structure by feature 2026-07-21 18:16:06 +08:00
2dust 4b903cf5e0 refactor(main): unify UI interactions with MainAction dispatcher
Refactored MainActivity and its Compose UI components to use a single
onAction: (MainAction) -> Unit callback. This significantly simplifies
component signatures and centralizes interaction logic.

- Updated MainContract.kt to include all UI-triggered actions.
- Modified MainScreen and sub-components (TopBar, BottomBar, Drawer, etc.)
  to use the unified onAction dispatcher.
- Implemented a centralized action handler in MainActivity to dispatch
  between ViewModel and Activity-specific tasks.
- Removed redundant ImportConfigViaSub action.
- Moved QR code generation state into MainUiState for MVI consistency.

This change reduces parameter drilling and improves maintainability of
the main interface.
2026-07-21 17:56:00 +08:00
2dust ccadd5453a Update LogcatActivity.kt 2026-07-20 17:48:05 +08:00
2dust 7213f9fa9f Refine settings states and selector rows
Improve settings UX consistency across Compose screens: disabled setting text now uses proper muted alpha for title/description, domain strategy and geo source pickers now use shared `SettingsListItem` instead of custom click/dialog state, routing rule row UI was extracted into a reusable composable, and several settings toggles were un-gated so they can be changed outside VPN-only conditions.
2026-07-20 17:05:42 +08:00
2dust 6a00dff160 Remove Start scanning immediately 2026-07-20 16:35:30 +08:00
2dust 4c27f8682a Update hev-socks5-tunnel 2026-07-20 15:26:43 +08:00
2dust 607311efd2 Update hev-socks5-tunnel 2026-07-20 15:26:17 +08:00
2dust f1eac6fb8c Refactor subscription update background workflow
Move manual subscription updates to WorkManager-based background jobs and simplify scheduler logic by handling auto-update checks in `scheduleOne`. Add post-update server testing support with optional invalid-node cleanup and sorting, plus MMKV/AngConfig helpers to remove multiple servers and reorder subscription servers by test delay. Update the subscriptions UI flow and zh-TW strings to reflect background execution and new auto-test options.
2026-07-20 15:16:21 +08:00
2dust 1b9401fc42 Refactor BaseViewModel, which inherits from AndroidViewModel. 2026-07-20 09:49:53 +08:00
2dust 1dee6230f9 Improve SubscriptionsViewModel 2026-07-19 21:22:35 +08:00
2dust ca79a574fa Update AndroidLibXrayLite 2026-07-19 17:19:16 +08:00
fuilloiand2dust eeec6de96c migrate MainActivity to MVVM (#5931)
* migrate MainActovity to MVVM

* MainViewModel simplify messaging system using BaseViewModel toasts

* refactor: simplify loading management using BaseViewModel

* Rename MainServiceEvent

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-07-19 15:39:12 +08:00
2dust b02647d664 Fix 2026-07-19 12:29:12 +08:00
2dust 90ae44e532 Refactor SettingsActivity to MVVM architecture 2026-07-19 12:26:07 +08:00
2dust aa55cdca76 Refactor CheckUpdateActivity to MVVM architecture 2026-07-19 12:12:45 +08:00
2dust 04a5c56844 Refactor ViewModel layer to a unified BaseViewModel architecture
- Implement BaseViewModel to centralize loading state and UI event handling.
- Migrate all ViewModels (Main, Backup, Logcat, etc.) to inherit from BaseViewModel.
- Convert MainViewModel from AndroidViewModel to standard ViewModel using global app context.
- Standardize UI events using ViewModelEvent/BaseViewModelEvent sealed interfaces.
- Simplify loading logic with launchLoading helper and direct toast notifications.
- Cleanup redundant loading/toast boilerplate code across the application.
- Update LogcatActivity to observe the unified isLoading state.
2026-07-19 11:41:48 +08:00
2dust aaf071053b Refactor BackupActivity to MVVM architecture
- Create BaseViewModel to encapsulate common UI state (isLoading) and logic (toasts, finishing activity).
- Implement ViewModelEvent and BaseViewModelEvent for standardized UI communication via SideEffects.
- Move core business logic (I/O, Zip, WebDAV) from BackupActivity to BackupViewModel.
- Standardize code naming conventions and update documentation to English.
- Improve Activity purity by delegating state management and background tasks to the ViewModel.
2026-07-19 11:01:08 +08:00
2dust 70352cc341 Add finalMask field for WireGuard
https://github.com/2dust/v2rayNG/issues/5891
2026-07-18 17:24:38 +08:00
2dust 1e3f16b083 Fix bugs for server 2026-07-18 16:57:59 +08:00
2dust 7cbb0febf0 Add package com.v2ray.ang.ui.server 2026-07-18 16:00:37 +08:00
2dust ac03b0e87b refactor: split single ServerActivity into separate per-protocol activities and encapsulate state in ServerUiState 2026-07-18 15:42:13 +08:00
2dust ca9b60fceb Fix typos 2026-07-18 10:13:41 +08:00
fuilloi 09caeec326 Fiximepadding (#5924)
* Revert "Add imePadding for edit"

This reverts commit c74a6cade3.

* fix imepadding
2026-07-18 09:58:24 +08:00
2dust 346c6cd60c Minor interface improvements 2026-07-17 20:45:34 +08:00
2dust c74a6cade3 Add imePadding for edit 2026-07-17 17:20:00 +08:00
2dust f6f981f9fd Adjust the allow_insecure style ,bug fix 2026-07-17 16:34:38 +08:00
eliotcougarand2dust 16cd06f07a Add configurable Observatory settings (#5893)
* Add configurable observatory settings

* Simplify code

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-07-17 15:09:06 +08:00
2dust 1eaea8e161 Add custom HTTP headers for subscription updates
https://github.com/2dust/v2rayNG/pull/5856
2026-07-17 14:30:39 +08:00
2dust 69cf1e70c1 Fixed a potential VPN connection drop caused by race conditions during multi-process service startup.
https://github.com/2dust/v2rayNG/pull/5888
2026-07-17 11:59:37 +08:00
2dust c49d7e98b1 Update libs.versions.toml 2026-07-17 10:15:01 +08:00
2dust 7ccac9eb2d Simplified snackbar 2026-07-16 17:15:45 +08:00
DHR60 1338ec8e64 V2rayN Inner URI (#5914)
* V2rayN Inner URI

* Remove `mportHopInt`
2026-07-16 15:42:37 +08:00
fuilloiand2dust b95db14005 migrate to compose (#5866)
* add composeui dependencies and component

* remove multidex

* BaseComponentActivity and HelperBaseComponentActivity

* migrate AppPicker/PerAppProxy Activity and use Snackbar

* migrate AboutActivity to composeui

* migrate BackupActivity to composeui

* migrate CheckUpdateActivity to composeui

* migrate Logcat to composeui

* migrate Scanner to composeui and camerax

* migrate sc start/stop/switch to compose

* migrate TaskerActivity to composeui

* migrate UrlSchemeActivity to composeui

* migrate UserAsset to composeui

* migrate ServerActivity to composeui

* migrate ServerCustomConfigActivity to composeui

* migrate ServerGroupActivity to composeui

* migrate ServerProxyChainActivity to composeui

* migrate SettingsActivity to composeui

* migrate SubEdit/SubSetting Activity to composeui

* migrate routingEdit/routingSetting Activity to composeui

* migrate MainActivity to composeui

* clode clean and chore change

* Fix

* add scrollbar

* fix imePadding

* fix FormDropdownField scrollbar and ime conflict‌

* update servercostomconfigactivity with linenum and scrollbar

* fix AppPickerActivity filter wrong toogle

* fix App theme xml

* fix scrollbar not work in SettingsActivity

* final inset

* fix servercustomconfigactivity when open not begin line

* try optimize mainactivity performance

* use launch intent and fix not refresh when second edit use equals

* fix a ghost error

* update string

* update miss string

* optimize mainactivity and mainviewmodel

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-07-16 15:18:56 +08:00
Alexey 8d726fa829 fix root authentication (#5910) 2026-07-15 09:05:07 +08:00
2dust 06e512f5c5 Flatten outbound settings across protocols 2026-07-10 17:46:20 +08:00
DHR60 9397a9e4a1 Fix (#5889) 2026-07-08 09:45:33 +08:00
JieXu 223eb1494b Update AppConfig.kt (#5884)
* Update AppConfig.kt

* Update build.yml
2026-07-08 09:45:08 +08:00
2dust 15b4fff8e4 up 2.2.6 2026-07-05 18:00:05 +08:00
2dust 1007f54743 Update AndroidLibXrayLite 2026-07-05 17:52:47 +08:00
2dust 5369303e18 Improve DNS, try fix
https://github.com/2dust/v2rayNG/issues/5828
2026-07-05 17:44:47 +08:00
Hossein Abaspanah 318afdbafc Update Luri Bakhtiari translation (#5876)
* Update strings.xml

* Update strings.xml

* Update strings.xml
2026-07-05 14:48:33 +08:00
MRVX ec04df4693 fix(i18n): overhaul and improve Persian (fa) translations (#5875)
- Fixed completely inaccurate and literal translations 
- Added missing string translations for newer features 
- Improved technical terminology to accurately reflect standard VPN and networking concepts in Persian 
- Rewrote the overall text for better readability and a more natural user experience.
2026-07-05 14:37:29 +08:00
2dust 53cccd9089 Add android.permission.ACCESS_LOCAL_NETWORK to Android 17
https://github.com/2dust/v2rayNG/issues/5854
2026-07-05 12:42:41 +08:00
2dust f226ace30d Code clean 2026-07-01 20:47:41 +08:00
JieXu 10a4221b25 Update README.md (#5852)
* Update README.md

* Update build.yml

* Update README.md
2026-07-01 20:20:39 +08:00
solokot ffac1476c0 Update Russian translation (#5851) 2026-07-01 20:19:18 +08:00
2dust 7a0472b63d Update hev-socks5-tunnel 2026-07-01 19:48:14 +08:00
2dust 62cecc4d0d Update AndroidLibXrayLite 2026-07-01 19:48:10 +08:00
kokoro eb56b2e89e Fixes "ip6tables: No chain/target/match by that name" error on Android devices lacking xt_addrtype kernel module support. (#5839) 2026-06-28 17:55:48 +08:00
Alizaand2dust e83dba94a0 feat: add a root, system-wide run mode without VpnService (#5812)
* feat: add a root, system-wide run mode without VpnService

Adds an optional Root mode for rooted devices that routes the whole device's
traffic through the existing in-process core without Android's VpnService, plus
an opt-in LAN/tethering sharing feature. Non-root devices are unaffected and keep
VPN / Proxy-only (VPN stays the default).

- ERunMode (VPN, PROXY_ONLY, TUN2SOCKS) persisted in the existing PREF_MODE;
  RootManager gates root modes (greyed-out for non-root, service refuses to start).
- CoreRootService + core/root/RootProxyManager run hev-socks5-tunnel as a
  standalone root process into the core's SOCKS inbound, steered by an iptables
  mangle MARK chain + a dedicated route table. Full TCP + UDP. hev-socks5-tunnel
  is the same engine already bundled for the VPN hev path, so no new third-party
  dependency is added.
- Capture parity with VpnService incl. per-app proxy/bypass; DNS funneled into the
  core (netd-aware, no uid filter) so names resolve through the configured
  resolver with no LAN-resolver leak.
- IPv6 parity: routed into the tun when enabled, otherwise native v6 is blackholed
  for the captured apps (REJECT) so they fall back to v4-through-proxy, like a
  v4-only VpnService.
- MTU taken from the existing VPN MTU setting; hev tun multi-queue + SOCKS
  tcp-fastopen enabled.
- CI fetches the hev-socks5-tunnel binary per-ABI from heiher/hev-socks5-tunnel
  releases (the same upstream the VPN hev path uses).

* build: compile libhevsockstun.so from source instead of downloading

Build the standalone hev-socks5-tunnel binary used by Root mode from the
pinned hev-socks5-tunnel submodule in compile-hevtun.sh, alongside the
existing JNI shared library, and drop the prebuilt release download from
the build workflow.

Both hev artifacts now come from the same in-tree source, so the binary
is fully auditable and version-locked to the submodule rather than a
fetched release asset. The executable is built without -DENABLE_LIBRARY
(so hev-main.c's main() is included) via BUILD_EXECUTABLE, reusing the
NDK toolchain already used for the JNI library.

* refactor(root): address review feedback

- LAN-sharing guard now checks the cheap, usually-false PREF_ROOT_LAN_SHARING
  preference before RootManager.cachedRoot(), so the common path short-circuits
  without touching root state.
- Move RootManager into the core.root package next to RootProxyManager and
  RootShell (CoreRootService stays under service/).
- Probe su only when the user opts into a root feature — selecting a root mode
  or enabling LAN sharing — instead of automatically on every Settings open.
  If root is denied the selection is reverted with a toast. This avoids an
  unsolicited root-grant prompt for the common non-root case; root mode for a
  persisted selection is still re-verified when the service starts.

* refactor(root): use coroutines instead of Thread for su probing

Replace raw Thread usage in the root path with kotlinx coroutines, as
requested in review. RootManager.refreshAsync (a callback + daemon Thread)
becomes a suspending refresh() that runs the blocking su probe on
Dispatchers.IO and returns the result.

Callers updated accordingly:
- SettingsActivity probes on demand via lifecycleScope.launch and updates
  the UI directly on resume (no manual runOnUiThread).
- CoreVpnService starts the LAN-sharing client over CoroutineScope(IO)
  instead of a daemon Thread.

* fix(root): don't capture all apps when per-app proxy resolves no uids

In allow (proxy-only) mode the mangle/v6 builders fell through to the
catch-all "mark/reject everything" branch whenever selectedUids was empty.
That is a fail-open: if the selected packages momentarily fail to resolve to
uids (e.g. at early boot, before PackageManager is ready), every unselected
app gets tunneled instead of none — a privacy leak and the cause of per-app
"proxying everything" after a reboot.

Gate the catch-all on the mode itself (all-apps or bypass) rather than on
"selected list happened to be non-empty". In allow mode mark only the
resolved uids; if none resolved, mark nothing (fail closed). Mirror the same
fix in the IPv6 blackhole chain.

* fix(root): wait for async rule setup before teardown on stop

CoreRootService/CoreVpnService post the foreground notification as soon as the
core starts but install the root routing rules in a launched coroutine, which
can take seconds (the setup script waits for the tun device to appear). If the
user stops the service during that window, onDestroy/stopAllService ran the
synchronous teardown first and the still-running setup then re-installed the
rules and tun afterwards — leaving orphan routing rules and a tun forwarding
into a now-dead core, which blackholes all traffic until the next start/stop
cycle clears it (the "disconnect from the notification kills the internet,
reconnect+disconnect to fix it" bug).

Track the setup job and cancelAndJoin it before tearing down so teardown always
runs last and removes everything the setup installed.

* Adjust root package and add RootLanSharing object

* Remove  ERunMode , add PREF_ROOT_MODE_ENABLE

* fix(root): handle tethered clients' IPv6 in LAN sharing to stop leaks

LAN/tethering sharing only set up IPv4 forwarding for clients, so a
hotspot/USB-tethered client with a native (RA-assigned) global IPv6
egressed the upstream interface directly, bypassing the proxy — an
IPv6 leak.

buildLanShareSetup now handles forwarded clients' v6:
- IPv6 enabled: route it through the tun. A mangle PREROUTING chain
  marks non-LOCAL-sourced (forwarded) v6 into the tun route table,
  keeps loopback/link-local/ULA/multicast direct, and hijacks client
  DNS; FORWARD accepts traffic to/from the tun. A trailing REJECT
  fails closed so anything not marked into the tun (e.g. addrtype
  match unavailable) is dropped instead of leaked.
- IPv6 disabled: REJECT all forwarded v6 (the device's own v6 is
  already blackholed in OUTPUT).

Teardown drops the two new ip6tables chains; the v6 route/rule into
the tun table were already cleaned.

Ported from vincentng295/Magic_V2Ray cae4f7f.

* Update build.gradle.kts

* Adjust settings

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-06-28 11:03:36 +08:00
autorepobot 42c12fdef4 Update build.yml (#5831) 2026-06-26 11:25:40 +08:00
dependabot[bot] 226b36903e Bump actions/cache from 5 to 6 (#5824)
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  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-06-25 19:09:39 +08:00
Enqvyand2dust de351d4438 add maxsplit fragment option (#5821)
* add maxsplit fragment option

* Add a docs folder

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-06-25 19:08:13 +08:00
2dust 5436d67e47 up 2.2.5 2026-06-20 18:00:33 +08:00
2dust 1c3d247071 Revert "Use decoded server list when removing servers"
This reverts commit dcd060e350.
2026-06-20 17:58:20 +08:00
2dust d3e1e7c6fe Bug fix
https://github.com/2dust/v2rayNG/issues/5798
2026-06-20 16:35:29 +08:00
2dust a65c57ea0d Remove Tcping 2026-06-20 16:07:07 +08:00
2dust 8586c2550f feat: add TCPing pre-check to speed up real ping test 2026-06-20 12:32:26 +08:00
dependabot[bot] fa6c4807f3 Bump actions/checkout from 6 to 7 (#5801)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-06-20 10:27:41 +08:00
DHR60 6e65f46e3f kcp (#5793) 2026-06-17 20:36:24 +08:00
MrArrowww 98e7b8e1a1 Remove forced fragment packet override for TLS (#5780) 2026-06-14 20:04:28 +08:00
solokot 7dd42528db Update Russian translation (#5781) 2026-06-14 20:03:09 +08:00
Hossein Abaspanah e580b76e9e Update strings.xml (#5779) 2026-06-14 20:02:55 +08:00
Ilya KaznacheevandIlya Kaznacheev c4d02c00ce Use 'restore' icon instead of 'delete' for "Restart Service" notification (#5783)
Co-authored-by: Ilya Kaznacheev <ilya.kaznacheev@axiomjdk.ru>
2026-06-14 20:02:39 +08:00
2dust 007a4e674f up 2.2.4 2026-06-14 11:35:05 +08:00
2dust 46af9d803c Improved insecure display 2026-06-14 11:34:53 +08:00
2dust 32a9263e0a Add vcn and pcs properties to VmessQRCode 2026-06-14 11:18:35 +08:00
2dust 253e171fec Update layout_tls.xml 2026-06-14 11:18:00 +08:00
2dust 31b66a705b Bug fix 2026-06-14 11:05:34 +08:00
2dust 23c7cd5b95 Update AndroidLibXrayLite 2026-06-14 10:57:32 +08:00
2dust 32a37dd626 Update strings.xml 2026-06-14 10:56:57 +08:00
53e84d5fd8 add fetch cert sha256 manual (#5749)
* add fetch cert sha256

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

* btn_pinned_ca256_action

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-14 10:50:50 +08:00
Skh-web6982 0519d02001 Update kotlin version to 2.4.0 (#5768)
* Update kotlin version to 2.4.0

* Update kotlin version to 2.4.0
2026-06-14 09:59:27 +08:00
MrArrowww 6dfed5bbc2 Add Fragment Packet 1-1 option to UI (#5774) 2026-06-14 09:59:07 +08:00
2dust dcd060e350 Use decoded server list when removing servers 2026-06-13 15:32:15 +08:00
2dust 71f94db929 Refresh group tab titles on server changes
https://github.com/2dust/v2rayNG/issues/5765
2026-06-13 15:20:06 +08:00
Hossein Abaspanah d06b9cb999 Update Luri Bakhtiari translation (#5737)
* Update strings.xml

* Update strings.xml
2026-06-06 09:42:35 +08:00
direnquanbuKO f53a91241c Correct & add some DNS Host ips (#5734) 2026-06-04 14:58:30 +08:00
solokot 3fb067dc99 Update Russian translation (#5730)
* Update Russian translation

* Update Russian translation
2026-06-04 14:52:36 +08:00
chlink2025 02103287e5 add verifyPeerCertByName (#5727) 2026-06-03 09:05:36 +08:00
2dust b103e3aec9 up 2.2.3 2026-06-02 18:39:35 +08:00
2dust 19fc65d128 Update allowInsecure deprecation messages and toast 2026-06-02 18:27:37 +08:00
2dust 27cafd1052 Deprecate allowInsecure and show warning toast
https://github.com/2dust/v2rayNG/issues/5717
2026-06-02 17:02:10 +08:00
2dust a155b24c1e up 2.2.2 2026-06-01 16:02:15 +08:00
2dust e805fcf088 Fix
https://github.com/2dust/v2rayNG/issues/5720
2026-06-01 15:55:06 +08:00
2dust 95e37db42e Update AndroidLibXrayLite 2026-06-01 15:05:39 +08:00
2dust fe812c4456 Remove allowInsecure preference and default to secure 2026-06-01 15:04:26 +08:00
fuilloi d5af0fe735 Update build.yml (#5718) 2026-06-01 13:46:07 +08:00
Hossein Abaspanah 9126aa9c9e Update Luri Bakhtiari translation (#5713)
* Update strings.xml

* Update strings new.xml

* Update strings.xml
2026-06-01 13:45:49 +08:00
Skh-web6982 4a763d2ec5 Update kotlin version to 2.3.21 (#5701)
* Update kotlin

* Update kotlin version to 2.3.21
2026-05-31 10:27:26 +08:00
Skh-web6982 08d6de4504 Upgrade Gradle wrapper (#5700) 2026-05-31 10:27:11 +08:00
Alexey 629d410189 fix: allow IPv6 policy group members (#5697) 2026-05-28 20:42:40 +08:00
2dust 39d2513df1 up 2.2.1 2026-05-27 19:59:55 +08:00
2dust 3cb4df474b Keep selected server when replacing subscription
https://github.com/2dust/v2rayNG/pull/5676
2026-05-27 19:53:18 +08:00
fuilloi b64bb92c8c Update MainRecyclerAdapter.kt (#5692)
* Update MainRecyclerAdapter.kt

Add text display for flow, network, and transport layer security.

* Update MainRecyclerAdapter.kt

remove flow
2026-05-27 17:30:22 +08:00
Tanbir Hossen 7f0e2f801d fix: migrate wsSettings host from deprecated headers.Host to independent host field (#5686)
* fix: migrate wsSettings host from deprecated headers.Host to independent host field

fix: migrate wsSettings host out of deprecated headers field

- Add independent `host` field to WsSettingsBean
- Replace HeadersBean with Map<String, String>? to match Xray docs
- Update CoreOutboundBuilder to use wssetting.host instead of wssetting.headers.Host

* fix: migrate wsSettings host from deprecated headers.Host to independent host field

fix: migrate wsSettings host out of deprecated headers field

- Add independent `host` field to WsSettingsBean
- Replace HeadersBean with Map<String, String>? to match Xray docs
- Update CoreOutboundBuilder to use wssetting.host instead of wssetting.headers.Host
2026-05-25 18:01:21 +08:00
2dust 0f7cee0613 Use isComplexType() to simplify config checks 2026-05-25 10:03:37 +08:00
2dust b2e10fc60b Skip complex config types when deduplicating servers
https://github.com/2dust/v2rayNG/issues/5675
2026-05-25 09:55:30 +08:00
name321467 aa3f17fb51 Add share log action (#5682)
Add a new "Share log" menu item in Logcat.The action creates a temporary .txt file from the current log output and opens the Android share sheet, allowing users to save or send logs to other apps.
2026-05-25 09:46:39 +08:00
2dust d64fc627bb up 2.2.0 2026-05-23 15:36:30 +08:00
2dust 6934ab3e5d Bump SDK to 37
Update compileSdk and targetSdk to 37. Adjust AndroidManifest: remove legacy storage read permissions, add tools:ignore for FOREGROUND_SERVICE policy and keep FOREGROUND_SERVICE_SPECIAL_USE with minSdk 34. Wrap ContextCompat.startForegroundService with SecurityException handling and special-case ForegroundServiceStartNotAllowedException (logged and rethrown as IllegalStateException) to surface permission/OS restrictions. Remove the READ_STORAGE PermissionType and related runtime permission request in HelperBaseActivity (file chooser now launches directly). These changes align permissions and service startup behavior with newer Android SDK requirements.
2026-05-23 14:56:45 +08:00
2dust 028d13aef8 Add SettingsManager.getProfileRemarks and use it 2026-05-23 11:09:46 +08:00
2dust 4a78c165bc Refactor core config, routing and balancer logic
Rework CoreConfigContextBuilder and CoreConfigManager to centralize and simplify outbound resolution and runtime config assembly. Key changes: handle CUSTOM profiles early, introduce ResolvedOutbound and resolvedOutbounds list, resolve routing-target outbounds separately, and unify normal/group/chain build flows into buildUnifiedConfig. Routing, DNS, inbounds, outbounds and balancer logic were modularized (configureInbounds/configureRouting/configureDns/configureFakeDns/configureLocalDns/etc.), balancer strategies and observability are consolidated, and domain pre-resolution/hosts handling preserved. Also added new balancer tag naming (TAG_BALANCER_PRE) and small API/utility adjustments across CoreOutboundBuilder, DTOs and enums to support the new flow. These changes improve clarity of the build pipeline, make custom/routing outbounds handling explicit, and prepare for richer policy-group balancing behavior.
2026-05-23 10:49:20 +08:00
2dust c1033a006f Use safe JSON parsing and add fromJsonSafe 2026-05-21 14:35:34 +08:00
DHR60 e8a42013c0 Remove freedom.domainStrategy (#5656) 2026-05-20 14:10:20 +08:00
vemneyy 94e5263250 Added QUIC sniffig option in destOverride to default config (#5639) 2026-05-20 14:06:56 +08:00
2dust 7c68aa9d83 Parse SOCKS userInfo without unnecessary decoding
https://github.com/2dust/v2rayNG/issues/5657
2026-05-20 13:59:54 +08:00
2dust ef13e336da up 2.1.8 2026-05-19 16:56:12 +08:00
2dust d16cb21059 Update AndroidLibXrayLite 2026-05-19 11:37:28 +08:00
2dust 237837e7be Refactor speed notification and add outbound stats
Add OutboundTrafficStat DTO and a CoreServiceManager.queryAllOutboundTrafficStats() method to fetch/reset all outbound counters in one core call and parse Go-side payload. Remove ProfileItem.getAllOutboundTags() and simplify NotificationManager API (startSpeedNotification()/stopSpeedNotification()) to use aggregated stats via the new core query. Replace per-tag polling with a single aggregated update loop (updateSpeedNotificationOnce) to reduce IPC and CPU usage, and update CoreServiceManager call sites accordingly.
2026-05-19 11:33:03 +08:00
2dust 2a6edd98be Add SOCKS4/5 and map-based parser lookup
https://github.com/2dust/v2rayNG/issues/5649
2026-05-19 09:48:49 +08:00
DHR60 9238b80675 Fix (#5651) 2026-05-18 09:18:53 +08:00
skl256 bb5e6f1959 Fix https://github.com/2dust/v2rayNG/issues/5619 (#5632)
Added "Level 0" matching to perform a full check of remarks, server, port, and password before falling back to partial matches. This ensures that identical profiles with the same remarks are correctly identified
2026-05-13 18:48:22 +08:00
2dust 1cc0865411 Add profile remark dropdowns to editor
Replace plain EditText fields for pre/next profile with AutoCompleteTextView + dropdown ImageButton in activity_sub_edit.xml and wire them up in SubEditActivity.kt. New setupProfileRemarkInputs() loads distinct non-blank server remarks from MmkvManager, sets an ArrayAdapter (threshold=0) and shows the dropdown when the field or button is clicked to make selecting existing profile remarks easier. Also adds necessary imports.
2026-05-12 11:37:47 +08:00
2dust cb8d979f23 Add dto.entities package 2026-05-12 11:25:37 +08:00
2dust 5932b0db29 Add UrlContentRequest and refactor HttpUtil
Introduce UrlContentRequest DTO to encapsulate url, timeout, httpPort, proxy credentials and userAgent. Refactor HttpUtil APIs (getUrlContent, getUrlContentWithUserAgent, downloadToFile) to accept UrlContentRequest and update callers accordingly in AngConfigManager, SpeedtestManager, UpdateCheckerManager, PerAppProxyActivity and UserAssetViewModel. This centralizes HTTP request parameters, reduces duplicated parameter lists, and preserves existing behaviors (redirect handling, userAgent fallback, proxy auth).
2026-05-12 11:05:11 +08:00
Hossein Abaspanah dbe4fe78b5 Update Luri Bakhtiari translation (#5628)
* Update Luri Bakhtiari translation

* Update strings new.xml

* Update strings.xml
2026-05-12 08:57:54 +08:00
2dust b5bcd81ae4 up 2.1.7 2026-05-10 14:17:02 +08:00
2dust d45ccd7953 Update AndroidLibXrayLite 2026-05-10 14:16:54 +08:00
solokot 6815563902 Update Russian translation (#5621) 2026-05-10 14:07:00 +08:00
2dust 7c38ad7c57 Improve WireGuard addresses 2026-05-09 16:48:09 +08:00
2dust 885aeb384e Refactor TCP HTTP request builder, relax Pragma
https://github.com/2dust/v2rayNG/issues/5623
2026-05-09 16:35:11 +08:00
2dust 57f01412e7 up 2.1.6 2026-05-09 11:42:37 +08:00
2dust fd84ea4114 Code cleanup: imports, formatting, and minor fixes 2026-05-09 11:42:23 +08:00
2dust a4e2e6d6a2 Fix,reverse proxy chain profiles order 2026-05-09 11:05:27 +08:00
2dust b8cfe9bc82 bump AGP to 9.2.1 2026-05-09 11:04:59 +08:00
DHR60andfish4terrisa-MSDSM b945f4a3c7 OkHttp Dialer (#5579)
AI
Add WebView dialer

Co-authored-by: fish4terrisa-MSDSM <flyingfish.msdsm@gmail.com>
2026-05-07 13:43:34 +08:00
fanymagnetandfanymagnet cfb6776e8f Get profile desc from server.address/port for vless (#5610)
Co-authored-by: fanymagnet <>
2026-05-07 10:03:47 +08:00
fanymagnetandfanymagnet ebaa5088b6 Show the save button and reboot the core upon saving (#5600)
Co-authored-by: fanymagnet <>
2026-05-05 19:23:41 +08:00
2dust 209679f098 Improve V2Ray config/error handling and startup
Add richer error reporting and simplify control flow across core components. Key changes:

- Add errorMessage to ConfigResult and populate it on failures in CoreConfigManager.getV2rayConfig/getV2rayConfig4Speedtest.
- Replace many nullable/boolean error returns in CoreConfigManager with explicit errors (error()/exceptions) and non-null return types; tighten validation (invalid servers, missing assets, parsing errors) and simplify inbound/dns/routing/outbound setup logic.
- Make getInbounds/getRouting/getDns/getOutbounds/getCustomLocalDns/getRoutingUserRule/getBalance non-failing (throw on error) and remove silent try/catch patterns; improve logging and fallback behaviors.
- CoreServiceManager: wrap startContextService calls with try/catch to surface errors to UI; startContextService now throws on invalid state/config and moves startup loop into doStartCoreLoop which registers receivers, starts core loop, and reports detailed failures to UI.
- Show notification when VPN service starts (CoreVpnService) to ensure foreground visibility.
- MainViewModel: display detailed start failure message from intent when available instead of only a generic toast.

These changes improve diagnostics, reduce silent failures, and centralize error handling during startup.
2026-05-05 18:45:47 +08:00
2dust 19ad248cfe Apply Basic auth from URL userinfo
https://github.com/2dust/v2rayNG/issues/5583
2026-05-05 10:40:09 +08:00
solokot 35f1f58476 Update Russian translation (#5595) 2026-05-04 10:52:11 +08:00
2dust 4c86e7c26e up 2.1.5 2026-05-03 20:21:49 +08:00
2dust 5d0897cd94 Update AndroidLibXrayLite 2026-05-03 20:21:17 +08:00
2dust fc4ab733c8 Introduce RealPingEvent and refactor ping flow
Add RealPingEvent sealed class and refactor real-ping flow to use event-driven communication. RealPingWorkerService now emits Progress/Result/Finish events instead of direct callbacks/MessageUtil calls. CoreTestService reorganized into handlers (handleMeasureStart/handleMeasureCancel/handleWorkerEvent) to process events, update notifications, persist delays via MmkvManager, and notify UI. MainViewModel adjusted to read the result content as a string when handling success messages. Overall change decouples worker logic from service responsibilities and simplifies progress/result handling.
2026-05-03 16:46:40 +08:00
DHR60 d606453b83 Fix (#5593) 2026-05-03 16:08:52 +08:00
DHR60 c6c8eccd51 Opt dns (#5592) 2026-05-03 14:52:55 +08:00
2dust 03074fba61 Add NotificationHelper and improve core test service
Introduce unified notification support and use foreground notifications for core test tasks. Added NotificationChannelType enum and NotificationHelper (cached NotificationManager and Builder cache, channel creation, notify/update/startForeground/stopForeground/cancel). Updated AndroidManifest to mark CoreTestService as a specialUse foreground service. Renamed AppConfig message constants (MSG_MEASURE_CONFIG_START, MSG_MEASURE_CONFIG_CANCEL, MSG_MEASURE_CONFIG_SUCCESS) and updated usages: MessageUtil now starts a foreground service on O+ for measurement start and handles cancel without forcing start; CoreTestService starts foreground immediately, logs lifecycle, manages RealPingWorkerService via onProgress/onFinish callbacks, and stops foreground when done; RealPingWorkerService reports progress via onProgress instead of direct UI messages; SubscriptionUpdater now uses NotificationHelper and NotificationChannelType for update notifications. These changes improve notification performance, ensure startForegroundService compliance on Android O+, and centralize notification logic.
2026-05-03 14:44:38 +08:00
2dust ac02d23083 Add real-ping concurrency setting
Introduce a configurable concurrency for real-ping (true delay) tests. Add PREF_REAL_PING_CONCURRENCY and DEFAULT_REAL_PING_CONCURRENCY in AppConfig, a getter SettingsManager.getRealPingConcurrency() (parses stored value, defaults to 16, clamps to 1..128), and use that value to create the RealPingWorkerService thread pool instead of cpu * 4. Expose the setting in preferences (EditTextPreference) and add localized title strings across multiple resource files. This lets users control how many concurrent real-ping workers run.
2026-05-03 09:42:36 +08:00
David 5c6aaf285b Correct translation errors (#5588)
* Update Chinese strings for UI and settings

* Refine summary for auto-remove invalid config

* Refine summaries for auto-remove and auto-sort preferences

* Change UI color setting label to '深色模式'
2026-05-03 09:16:32 +08:00
DHR60 d3f9809486 Fix dns (#5587) 2026-05-03 09:09:10 +08:00
2dust a12909e33d Add proxy-chain feature (UI & core support)
Introduce a new Proxy Chain server type and full support across UI and core logic. Added EConfigType.PROXYCHAIN and ProfileItem.proxyChainProfiles, new ServerProxyChainActivity, ServerProxyChainMemberAdapter, layouts and menu entries, and localized strings. CoreConfigContextBuilder now resolves proxy-chain members (from explicit list or subscription group) with robust filtering and error logging; CoreConfigManager validates chain length and logs warnings. CoreOutboundBuilder and CoreServiceManager were updated to treat PROXYCHAIN as a non-directly-runnable config type. Various UI flows (GroupServerFragment, MainActivity, ServerActivity) updated to handle the new type.
2026-05-02 19:00:17 +08:00
2dust 3e823a7440 Pre-resolve and inject custom outbounds
Pre-resolve outbound profiles referenced by routing rules and pass them through the runtime context so they can be injected into generated V2ray configs.

Key changes:
- Added customOutboundProfiles: Map<String, ProfileItem> to CoreConfigContext.
- CoreConfigContextBuilder: new resolveCustomOutbounds() to collect non-builtin outbound tags from routing rulesets and build the map; builder now populates customOutboundProfiles and simplifies resolvedProfiles/type handling.
- CoreConfigManager: refactored many APIs to accept CoreConfigContext instead of raw Context/guid (toConfigResult, initV2rayConfig, getRouting, getOutbounds, getBalance, etc.), and uses the pre-resolved customOutboundProfiles when injecting custom outbounds.
- injectCustomOutbounds now accepts the pre-resolved map and converts ProfileItem -> Outbound only for missing tags.
- Added logging/error handling and minor tidying to avoid repeated lookups and ensure custom outbounds are available before routing is applied.

Rationale: ensure outbounds referenced by rulesets are available early in the build pipeline, reduce duplicate lookups, and centralize runtime data in CoreConfigContext.
2026-05-02 14:47:03 +08:00
2dust dc1d3a27dd Introduce CoreConfigContext and refactor manager
Add CoreConfigContext DTO and CoreResolvedType enum, and implement CoreConfigContextBuilder to centralize profile resolution (CUSTOM, POLICYGROUP, PROXYCHAIN/NORMAL) with policy-group filters and proxy-chain ordering (next -> current -> prev).

Refactor CoreConfigManager to consume CoreConfigContext: replace prior branching with buildGroupConfig, buildProxyChainConfig and buildNormalConfig, unify CUSTOM handling, add postProcessForSpeedtest and toConfigResult helpers, and remove the old getMoreOutbounds logic. Adjust outbound assembly, DNS/route handling and speedtest post-processing accordingly.

Minor cleanup: centralize outbound conversion comments in CoreOutboundBuilder and tidy several condition checks and log lines. Overall this extracts resolution logic, reduces duplication and clarifies the runtime build flow.
2026-05-02 10:53:53 +08:00
2dust d0f1e21f49 Move outbound global settings to builder
Extract updateOutboundWithGlobalSettings from CoreConfigManager into CoreOutboundBuilder and apply it during outbound conversion. CoreConfigManager no longer calls the function (and its NetworkType import was removed); CoreOutboundBuilder.convert now runs the global-settings update and returns null if the update fails. This centralizes outbound/global preference handling and eliminates duplicated update logic across the codebase.
2026-05-01 19:11:21 +08:00
2dust 3b8615fad0 Rename V2Ray components to Core
Refactor: rename handler package/classes and services from V2Ray*/V2ray* to Core*/Core (e.g. V2rayConfigManager -> CoreConfigManager, V2RayNativeManager -> CoreNativeManager, V2RayServiceManager -> CoreServiceManager, and service classes V2RayVpnService/V2RayProxyOnlyService/V2RayTestService -> CoreVpnService/CoreProxyOnlyService/CoreTestService). Update AndroidManifest service entries and all imports/usages across the app (notifications, receivers, widgets, UI, services, workers) to reference the renamed classes and package (com.v2ray.ang.core). Comments and references were adjusted to match the new names; no functional logic changes intended.
2026-05-01 18:48:43 +08:00
2dust d9d00f4137 Add CoreOutboundBuilder to build OutboundBeans
Introduce CoreOutboundBuilder.kt to centralize ProfileItem → OutboundBean conversion logic. Provides createInitOutbound and per-protocol builders for VMESS, VLESS, Shadowsocks, SOCKS, Trojan, WireGuard, Hysteria2 and HTTP. Includes reusable helpers populateTransportSettings and populateTlsSettings to configure network transport, TLS/REALITY settings, finalmask manipulation and optional fragmentation via MMKV prefs. Also adds domain resolution logic (with IPv6 preference) in getServerAddress and preserves original format-specific behaviors.
2026-05-01 17:06:53 +08:00
2dust 2aefbd43eb Suppress exceptions in RealPingWorkerService
Wrap startRealPing(guid) in a catch(Throwable) that ignores errors so exceptions during measurement won't propagate. This ensures the finally block always runs to decrement totalCount and runningCount, preventing the worker from being interrupted by unexpected errors.
2026-05-01 17:06:34 +08:00
2dust cf33391785 up 2.1.4 2026-04-30 11:30:31 +08:00
dependabot[bot] a35ba5dfbd Bump timheuer/base64-to-file from 1.2.4 to 2.0.0 (#5575)
Bumps [timheuer/base64-to-file](https://github.com/timheuer/base64-to-file) from 1.2.4 to 2.0.0.
- [Release notes](https://github.com/timheuer/base64-to-file/releases)
- [Commits](https://github.com/timheuer/base64-to-file/compare/v1.2.4...v2.0.0)

---
updated-dependencies:
- dependency-name: timheuer/base64-to-file
  dependency-version: 2.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-04-30 10:52:30 +08:00
Copilotand2dust 3b4d895c1d Refactor: centralize subscription auto-update scheduling in SubscriptionUpdater (#5567)
* Initial plan

* Refactor subscription auto-update system into SubscriptionUpdater facade

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/63125c89-4db9-4572-8adb-10c29ea531f5

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

* Add TODO comment to legacy fallback in SubscriptionUpdater

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/63125c89-4db9-4572-8adb-10c29ea531f5

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

* Use per-subscription auto-update intervals

Migrate subscription auto-update from a global toggle/interval to per-subscription scheduling.

- Add per-subscription updateInterval (Long, default 1440 minutes) and persist in SubEdit UI; validate minimum interval (15 minutes).
- Update SubscriptionUpdater to schedule/cancel tasks per subscription, compute initial delay from subscription.lastUpdated, and skip when autoUpdate is disabled or subId is missing.
- Remove legacy global auto-update prefs and fallback logic: deleted global prefs/UI handling, MmkvManager last-attempt encode/decode, and related default setting.
- Ensure cancelling of scheduled task when removing a subscription and adjust SettingsManager initialization.
- Add localized toast strings for invalid update interval and update related layouts/resources.

This change enables individual update intervals per subscription and simplifies the updater by removing legacy global state and last-attempt persistence.

* Allow optional force-reschedule for subscriptions

Add a forceReschedule flag to SubscriptionUpdater.sync to choose the WorkManager policy (KEEP by default, REPLACE when forced). Refactor scheduleOne to accept an ExistingPeriodicWorkPolicy and use it when enqueuing periodic work; syncOne now forces REPLACE so a manual refresh recalculates next run. Remove the cancelAll helper and improve logging to include the reschedule flag/policy. This lets startup keep existing periodic work while allowing explicit reschedules when needed (e.g., after manual updates).

---------

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-30 10:52:14 +08:00
2dust e8585a82c6 Remove echForceQuery
Remove echForceQuery support across the app: delete the echForceQuery fields from ProfileItem and V2rayConfig, stop mapping it in V2rayConfigManager, and remove the related UI spinner and containers in ServerActivity and layout_tls.xml. Also delete the ech_force_query_value array and all localized string entries for the ECH force query label, and adjust focus navigation to skip the removed control. This cleans up a deprecated/unused ECH force query setting.
2026-04-29 20:53:28 +08:00
Copilotand2dust 087c08f95a fix: immediately reconfigure WorkManager when auto-update interval changes (#5564)
* Initial plan

* fix: reconfigure WorkManager task when auto-update interval changes

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/2fd439e4-ecb0-4594-a90f-fbfe1e81b021

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-27 20:59:02 +08:00
Hossein Abaspanah 6612ca8256 Update Luri Bakhtiari translation (#5554) 2026-04-27 18:55:03 +08:00
solokot 487692deb7 Update Russian translation (#5553) 2026-04-27 18:54:47 +08:00
2dust e9332168bf up 2.1.3 2026-04-26 11:23:14 +08:00
2dust 15fcfec4c8 Update AndroidLibXrayLite 2026-04-26 11:19:29 +08:00
a4baeeb0fa Add options to turn off local proxy completely or turn off UDP in SOCKS5 (#5548)
* Add options to turn off local proxy completely or turn off UDP in SOCKS5.

* Improve consistency of code style

* Auto-disable additional http proxy when local proxy is disabled

* Add local proxy summary and wire to prefs

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

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

---------

Co-authored-by: hippo2025 <4xcw60t6e@mozmail.com>
Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 11:14:00 +08:00
2dustandCopilot 093e51186d Upgrade Android Gradle Plugin and Gradle wrapper
Bump AGP to 9.2.0 in gradle/libs.versions.toml and update Gradle wrapper distribution to gradle-9.4.1 in gradle/wrapper/gradle-wrapper.properties. Keeps the build tooling up to date to take advantage of Gradle/AGP fixes and improvements.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-25 20:11:03 +08:00
2dustandCopilot 14a29884e7 Disable VPN session name setting
Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-25 20:10:33 +08:00
solokot 30b0bd8d0d Update Russian translation (#5538)
* Update Russian translation

* Update Russian translation
2026-04-25 19:56:04 +08:00
ae84df7367 Add MTU and TTI settings in KCP network (#5539)
* Add MTU and TTI settings in KCP network

* Rename ProfileItem.tti to kcpTti

Rename the ProfileItem field `tti` to `kcpTti` and update all usages accordingly. Changes update parsing/serialization in FmtBase (map query param "tti" to config.kcpTti and emit it), KCP stream settings mapping in V2rayConfigManager, UI bindings in ServerActivity, and equality checks in ProfileItem to use kcpTti. This clarifies that the field applies to KCP-specific TTI values while preserving the external query key.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>

* Add kcpMtu/kcpTti for KCP transport

Introduce kcpMtu and kcpTti properties on ProfileItem and update equality checks. Parse and emit KCP-specific mtu/tti in FmtBase (use kcpMtu/kcpTti instead of the generic mtu). Wire kcpMtu into V2rayConfigManager when building KCP stream settings and update ServerActivity to read/write the UI field to profileItem.kcpMtu/kcpTti. This separates KCP MTU/TTI from the general mtu field and removes a duplicated kcpTti entry.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-25 19:55:44 +08:00
hippo2025andCopilot b673167731 Return null in IP test if http port is 0 (#5537)
new name

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-25 19:13:23 +08:00
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
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
Kapkap5454and2dust 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
Copilotand2dust 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
DHR60andCopilot 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
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
310 changed files with 22279 additions and 15035 deletions

No files matched your search

+52 -19
View File
@@ -10,37 +10,40 @@ on:
branches:
- master
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup Android SDK
uses: android-actions/setup-android@v3.2.0
uses: android-actions/setup-android@v4
with:
log-accepted-android-sdk-licenses: false
cmdline-tools-version: '13114758'
packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
cmdline-tools-version: '14742923'
packages: 'platforms;android-37.0 build-tools;37.0.0 platform-tools'
- name: Install NDK
run: |
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
--channel=0 \
--install "ndk;28.2.13676358"
echo "NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> $GITHUB_ENV
--install "ndk;29.0.14206865"
echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
sed -i '10i\
\
ndkVersion = "28.2.13676358"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
ndkVersion = "29.0.14206865"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
- name: Restore cached libhevtun
id: cache-libhevtun-restore
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: ${{ github.workspace }}/libs
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
@@ -52,7 +55,7 @@ jobs:
- name: Save libhevtun
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
uses: actions/cache/save@v6
with:
path: ${{ github.workspace }}/libs
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
@@ -70,7 +73,7 @@ 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 }}
@@ -78,13 +81,13 @@ jobs:
out-file-path: V2rayNG/app/libs/
- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: '21'
- name: Decode Keystore
uses: timheuer/base64-to-file@v1.2.4
uses: timheuer/base64-to-file@v2.0.0
id: android_keystore
with:
fileName: "android_keystore.jks"
@@ -119,11 +122,41 @@ jobs:
name: x86-apk
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*x86*.apk
- name: Upload to release
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
- name: Upload universal APK
uses: actions/upload-artifact@v7
if: ${{ success() }}
with:
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*.apk
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
prerelease: true
name: universal-apk
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*universal*.apk
sign-and-release:
needs: build
runs-on: ubuntu-26.04
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
steps:
- name: Download APK artifacts
uses: actions/download-artifact@v8
with:
path: release
- name: Sign APK files
run: |
printf '%s' "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
KEY=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ {print $10; exit}')
mkdir -p release-files
find release -type f -name '*.apk' | while read apk; do
cp "$apk" release-files/
gpg --batch --yes --local-user "$KEY" --detach-sign --output "release-files/$(basename "$apk").sig" "$apk"
done
gpg --armor --export "$KEY" > release-files/v2rayN-public-key.asc
- name: Upload APKs, signatures and public key to release
uses: svenstaro/upload-release-action@v2
with:
file: release-files/*
tag: ${{ inputs.release_tag }}
file_glob: true
prerelease: true
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Validate Fastlane Supply Metadata
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
+72 -14
View File
@@ -3,30 +3,88 @@
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-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)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.4.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)
### Telegram Channel
[github_2dust](https://t.me/github_2dust)
---
### Usage
## Download / 下载
Download the latest release here:
在这里下载最新版本:
[https://github.com/2dust/v2rayNG/releases](https://github.com/2dust/v2rayNG/releases)
> [!TIP]
> v2rayNG is the mobile version. For the desktop version, please visit the v2rayN \
> v2rayNG 是手机版,电脑版请访问 v2rayN
>
> https://github.com/2dust/v2rayN
---
### Geoip and Geosite
#### Geoip and Geosite
- geoip.dat and geosite.dat files are in `Android/data/com.v2ray.ang/files/assets` (path may differ on some Android device)
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (Note it need a working proxy)
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (note: it needs a working proxy)
- latest official [domain list](https://github.com/Loyalsoldier/v2ray-rules-dat) and [ip list](https://github.com/Loyalsoldier/geoip) can be imported manually
- possible to use third party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
- possible to use a third-party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
### More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
### Development guide
### Geoip 与 Geosite
Android project under V2rayNG folder can be compiled directly in Android Studio, or using Gradle wrapper. But the v2ray core inside the aar is (probably) outdated.
The aar can be compiled from the Golang project [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite).
For a quick start, read guide for [Go Mobile](https://github.com/golang/go/wiki/Mobile) and [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/)
- geoip.dat 和 geosite.dat 文件位于 `Android/data/com.v2ray.ang/files/assets`(部分设备路径可能不同)
- 下载功能将获取该 [仓库](https://github.com/Loyalsoldier/v2ray-rules-dat) 中的增强版本(注意:此功能需要一个可用的代理)
- 最新官方 [域名列表](https://github.com/Loyalsoldier/v2ray-rules-dat) 和 [IP 列表](https://github.com/Loyalsoldier/geoip) 可手动导入
- 也可在同一文件夹中使用第三方 dat 文件,例如 [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
v2rayNG can run on Android Emulators. For WSA, VPN permission need to be granted via
`appops set [package name] ACTIVATE_VPN allow`
更多内容请见我们的 [wiki](https://github.com/2dust/v2rayNG/wiki)
---
## Development guide / 开发指南
### Note
- Android project under the V2rayNG folder can be compiled directly in Android Studio, or using the Gradle wrapper. But the v2ray core inside the aar is (probably) outdated.
- The aar can be compiled from the Golang project [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite). For a quick start, read the guides for [Go Mobile](https://github.com/golang/go/wiki/Mobile) and [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/).
- v2rayNG can run on Android Emulators. For WSA, VPN permission needs to be granted via `appops set [package name] ACTIVATE_VPN allow`.
### 提示
- V2rayNG 文件夹下的 Android 项目可直接在 Android Studio 中编译,或使用 Gradle wrapper 编译。但 aar 内置的 v2ray core(可能)已过时。
- aar 可由 Golang 项目 [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) 或 [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite) 编译而成。快速入门可参考 [Go Mobile](https://github.com/golang/go/wiki/Mobile) 指南和 [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/)。
- v2rayNG 可在 Android 模拟器上运行。对于 WSA,需要通过 `appops set [package name] ACTIVATE_VPN allow` 授予 VPN 权限。
---
## GPG Verification / GPG 签名校验
Release files are signed with GPG to verify authenticity and integrity, helping prevent mirror, ISP, or CDN hijacking.
发布文件已使用 GPG 签名,可用于校验文件真实性与完整性,预防镜像站、运营商或 CDN 劫持。
### Fingerprint / 公钥指纹
```text
7694 5E9F 3E9A 168F 8070 F195 805D 661C
134D FAF6 8903 C199 463C 31E5 AE90 3AE0
```
---
## Community / 社区
Telegram Group / Telegram 群组:
[https://t.me/v2rayN](https://t.me/v2rayN)
Telegram Channel / Telegram 频道:
[https://t.me/github_2dust](https://t.me/github_2dust)
+27 -30
View File
@@ -1,27 +1,27 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.jaredsburrows.license")
}
android {
namespace = "com.v2ray.ang"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 36
versionCode = 716
versionName = "2.0.16"
multiDexEnabled = true
targetSdk = 37
versionCode = 740
versionName = "2.3.0"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
abi {
isEnable = true
reset()
if (abiFilterList != null && abiFilterList.isNotEmpty()) {
if (!abiFilterList.isNullOrEmpty()) {
include(*abiFilterList.toTypedArray())
} else {
include(
@@ -124,8 +124,8 @@ android {
}
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
packaging {
@@ -142,20 +142,19 @@ dependencies {
// AndroidX Core Libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.preference.ktx)
implementation(libs.recyclerview)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.fragment)
// UI Libraries
implementation(libs.material)
implementation(libs.toasty)
implementation(libs.editorkit)
implementation(libs.flexbox)
// Compose Libraries
implementation(platform(libs.androidx.compose.bom))
androidTestImplementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.lifecycle.runtime.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
// Data and Storage Libraries
implementation(libs.mmkv.static)
@@ -166,25 +165,23 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
// Language and Processing Libraries
implementation(libs.language.base)
implementation(libs.language.json)
// Intent and Utility Libraries
implementation(libs.quickie.foss)
implementation(libs.core)
// QR Code: CameraX + ZXing
implementation(libs.camerax.core)
implementation(libs.camerax.camera2)
implementation(libs.camerax.lifecycle)
implementation(libs.camerax.compose)
implementation(libs.core) // zxing core
// AndroidX Lifecycle and Architecture Components
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.lifecycle.livedata.ktx)
implementation(libs.lifecycle.runtime.ktx)
// Background Task Libraries
implementation(libs.work.runtime.ktx)
implementation(libs.work.multiprocess)
// Multidex Support
implementation(libs.multidex)
// Reorderable list
implementation(libs.reorderable)
// Testing Libraries
testImplementation(libs.junit)
+32 -2
View File
@@ -11,7 +11,7 @@
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScSwitchActivity"
android:targetClass="com.v2ray.ang.ui.shortcut.ScSwitchActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
@@ -26,7 +26,37 @@
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScScannerActivity"
android:targetClass="com.v2ray.ang.ui.shortcut.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.shortcut.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.shortcut.ScStopActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
+110 -28
View File
@@ -29,16 +29,16 @@
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
android:minSdkVersion="34" />
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
@@ -53,7 +53,7 @@
android:usesCleartextTraffic="true">
<activity
android:name=".ui.MainActivity"
android:name=".ui.main.MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
@@ -71,53 +71,102 @@
android:resource="@xml/shortcuts" />
</activity>
<activity
android:name=".ui.ServerActivity"
android:name=".ui.server.ServerVmessActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.ServerCustomConfigActivity"
android:name=".ui.server.ServerVlessActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.ServerGroupActivity"
android:name=".ui.server.ServerShadowsocksActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.SettingsActivity"
android:name=".ui.server.ServerSocksActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.server.ServerHttpActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.server.ServerTrojanActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.server.ServerWireguardActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.server.ServerHysteria2Activity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.server.ServerCustomConfigActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.server.ServerGroupActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.server.ServerProxyChainActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.settings.SettingsActivity"
android:exported="false" />
<activity
android:name=".ui.PerAppProxyActivity"
android:name=".ui.perappproxy.PerAppProxyActivity"
android:exported="false" />
<activity
android:name=".ui.apppicker.AppPickerActivity"
android:exported="false" />
<activity
android:name=".ui.ScannerActivity"
android:exported="false" />
<activity
android:name=".ui.LogcatActivity"
android:name=".ui.logcat.LogcatActivity"
android:exported="false" />
<activity
android:name=".ui.RoutingSettingActivity"
android:name=".ui.routing.RoutingSettingActivity"
android:exported="false" />
<activity
android:name=".ui.RoutingEditActivity"
android:name=".ui.routing.RoutingEditActivity"
android:windowSoftInputMode="stateUnchanged|adjustResize"
android:exported="false" />
<activity
android:name=".ui.SubSettingActivity"
android:name=".ui.subscription.SubSettingActivity"
android:exported="false" />
<activity
android:name=".ui.UserAssetActivity"
android:name=".ui.userasset.UserAssetActivity"
android:exported="false" />
<activity
android:name=".ui.UserAssetUrlActivity"
android:name=".ui.userasset.UserAssetUrlActivity"
android:exported="false" />
<activity
android:name=".ui.SubEditActivity"
android:name=".ui.subscription.SubEditActivity"
android:windowSoftInputMode="stateUnchanged|adjustResize"
android:exported="false" />
<activity
android:name=".ui.ScScannerActivity"
android:name=".ui.shortcut.ScScannerActivity"
android:exported="false" />
<activity
android:name=".ui.ScSwitchActivity"
android:name=".ui.shortcut.ScSwitchActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.shortcut.ScStartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.shortcut.ScStopActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
@@ -143,17 +192,17 @@
</intent-filter>
</activity>
<activity
android:name=".ui.CheckUpdateActivity"
android:name=".ui.checkupdate.CheckUpdateActivity"
android:exported="false" />
<activity
android:name=".ui.BackupActivity"
android:name=".ui.backup.BackupActivity"
android:exported="false" />
<activity
android:name=".ui.AboutActivity"
android:exported="false" />
<service
android:name=".service.V2RayVpnService"
android:name=".service.CoreVpnService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
@@ -173,7 +222,7 @@
</service>
<service
android:name=".service.V2RayProxyOnlyService"
android:name=".service.CoreProxyOnlyService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
@@ -184,9 +233,35 @@
</service>
<service
android:name=".service.V2RayTestService"
android:name=".service.CoreRootService"
android:exported="false"
android:process=":RunSoLibV2RayDaemon" />
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":RunSoLibV2RayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service
android:name=".service.CoreTestService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
</service>
<service
android:name=".service.SubscriptionUpdateService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
</service>
<receiver
android:name=".receiver.WidgetProvider"
@@ -207,6 +282,8 @@
android:label="BootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
@@ -227,7 +304,7 @@
</service>
<!-- =====================Tasker===================== -->
<activity
android:name=".ui.TaskerActivity"
android:name=".ui.shortcut.TaskerActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher">
<intent-filter>
@@ -258,6 +335,11 @@
</provider>
<service
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
android:exported="false"
android:process=":bg" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.cache"
@@ -30,7 +30,8 @@
"enabled": true,
"destOverride": [
"http",
"tls"
"tls",
"quic"
]
}
}
@@ -73,8 +74,10 @@
},
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIP"
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP"
}
},
"tag": "direct"
},
@@ -30,13 +30,13 @@
"enabled": true,
"destOverride": [
"http",
"tls"
"tls",
"quic"
]
}
},
{
"tag": "tun",
"port": 0,
"protocol": "tun",
"settings": {
"name": "xray0",
@@ -47,7 +47,8 @@
"enabled": true,
"destOverride": [
"http",
"tls"
"tls",
"quic"
]
}
}
@@ -90,8 +91,10 @@
},
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIP"
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP"
}
},
"tag": "direct"
},
@@ -1,14 +1,15 @@
package com.v2ray.ang
import android.app.Application
import android.content.Context
import androidx.multidex.MultiDexApplication
import androidx.work.Configuration
import androidx.work.WorkManager
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.compose.ThemeManager
class AngApplication : MultiDexApplication() {
class AngApplication : Application() {
companion object {
lateinit var application: AngApplication
}
@@ -39,10 +40,8 @@ class AngApplication : MultiDexApplication() {
// 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()
// Initialize theme state from MMKV
ThemeManager.refresh()
}
}
@@ -10,8 +10,8 @@ object AppConfig {
/** Directory names used in the app's file system. */
const val DIR_ASSETS = "assets"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
/** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config"
@@ -43,21 +43,29 @@ object AppConfig {
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
const val SUBSCRIPTION_AUTO_UPDATE = "pref_auto_update_subscription"
const val SUBSCRIPTION_AUTO_UPDATE_INTERVAL = "pref_auto_update_interval"
const val SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL = "1440" // Default is 24 hours
const val PREF_FRAGMENT_MAXSPLIT = "pref_fragment_maxsplit"
const val PREF_OBSERVATORY_LEAST_PING_INTERVAL = "pref_observatory_least_ping_interval"
const val PREF_OBSERVATORY_LEAST_LOAD_INTERVAL = "pref_observatory_least_load_interval"
const val PREF_OBSERVATORY_LEAST_LOAD_METHOD = "pref_observatory_least_load_method"
const val PREF_OBSERVATORY_LEAST_LOAD_SAMPLING = "pref_observatory_least_load_sampling"
const val PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT = "pref_observatory_least_load_timeout"
const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
const val SUBSCRIPTION_MIN_INTERVAL_MINUTES = 15L
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
const val PREF_LANGUAGE = "pref_language"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
const val PREF_ENABLE_LOCAL_PROXY = "pref_enable_local_proxy"
const val PREF_SOCKS_PORT = "pref_socks_port"
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
const val PREF_SOCKS_USERNAME = "pref_socks_username"
const val PREF_SOCKS_PASSWORD = "pref_socks_password"
const val PREF_SOCKS_ENABLE_UDP = "pref_socks_enable_udp"
const val PREF_REMOTE_DNS = "pref_remote_dns"
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
const val PREF_DNS_HOSTS = "pref_dns_hosts"
@@ -66,14 +74,19 @@ object AppConfig {
const val PREF_LOGLEVEL = "pref_core_loglevel"
const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method"
const val PREF_MODE = "pref_mode"
const val PREF_ROOT_MODE_ENABLE = "pref_root_mode_enabled"
const val PREF_ROOT_LAN_SHARING = "pref_root_lan_sharing"
const val PREF_IS_BOOTED = "pref_is_booted"
const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_UPDATE_SUBSCRIPTION = "pref_update_subscription"
const val PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION = "pref_auto_test_after_update_subscription"
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
@@ -100,7 +113,8 @@ object AppConfig {
const val TAG_FRAGMENT = "fragment"
const val TAG_DNS = "dns-module"
const val TAG_DOMESTIC_DNS = "domestic-dns"
const val TAG_BALANCER = "proxy-round"
const val TAG_BALANCER = "balancer-main"
const val TAG_BALANCER_PRE = "balancer"
/** Network-related constants. */
const val UPLINK = "uplink"
@@ -120,11 +134,17 @@ object AppConfig {
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 OBSERVATORY_LEAST_PING_INTERVAL = "3m"
const val OBSERVATORY_LEAST_LOAD_INTERVAL = "5m"
const val OBSERVATORY_LEAST_LOAD_METHOD = "HEAD"
const val OBSERVATORY_LEAST_LOAD_SAMPLING = "2"
const val OBSERVATORY_LEAST_LOAD_TIMEOUT = "30s"
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
const val IP_API_URL = "https://api.ip.sb/geoip"
/** DNS server addresses. */
const val DNS_PROXY = "1.1.1.1"
const val DNS_PROXY = "https://cloudflare-dns.com/dns-query"
const val DNS_DIRECT = "223.5.5.5"
const val DNS_VPN = "1.1.1.1"
const val GEOSITE_PRIVATE = "geosite:private"
@@ -159,23 +179,27 @@ object AppConfig {
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
const val MSG_MEASURE_DELAY_SUCCESS = 61
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_START = 7
const val MSG_MEASURE_CONFIG_CANCEL = 71
const val MSG_MEASURE_CONFIG_SUCCESS = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
const val MSG_SUB_UPDATE_START = 8
const val MSG_SUB_UPDATE_CANCEL = 81
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
// Use a new ID because Android does not let an app raise an existing channel's importance.
const val RAY_NG_CHANNEL_ID = "CORE_M_CH_ID_V2"
const val RAY_NG_CHANNEL_NAME = "Core Background Service"
/** Protocols Scheme **/
const val VMESS = "vmess://"
const val CUSTOM = ""
const val SHADOWSOCKS = "ss://"
const val SOCKS = "socks://"
const val SOCKS4 = "socks4://"
const val SOCKS5 = "socks5://"
const val HTTP = "http://"
const val VLESS = "vless://"
const val TROJAN = "trojan://"
@@ -184,11 +208,34 @@ object AppConfig {
const val HYSTERIA = "hysteria://"
const val HYSTERIA2 = "hysteria2://"
const val HY2 = "hy2://"
const val V2RAYNFMTS = "v2rayn://"
/** Give a good name to this, IDK*/
const val VPN = "VPN"
const val VPN_MTU = 1500
/** Root (system-wide) mode runtime constants. */
const val ROOT_RUNTIME_DIR = "sys_cache"
const val ROOT_IPTABLES_CHAIN = "CORE_FILTER"
const val ROOT_FWMARK = 255 // defensive RETURN tag; hev's only upstream socket is loopback (already bypassed)
const val ROOT_MARK_ROUTE = 1 // packets we want pushed into the tun device
const val ROOT_ROUTE_TABLE = 2024
const val ROOT_RULE_PRIORITY = 1000
const val ROOT_TUN_NAME = "utun7788"
const val ROOT_TUN_ADDR_V4 = "198.18.0.1/15"
const val ROOT_TUN_ADDR_V6 = "fdfe:dcba:9876::1/64"
// hev-socks5-tunnel run as a standalone root binary (reuses the same project already
// bundled for the VPN hev path; distinct filename from the JNI lib to avoid collision).
const val ROOT_TUN2SOCKS_BIN = "libhevsockstun.so"
const val ROOT_FWD_CHAIN = "CORE_FWD" // FORWARD chain for LAN/tethering sharing
const val ROOT_DNS_CHAIN = "CORE_DNS" // nat chain for tethered-client DNS DNAT
const val ROOT_V6_CHAIN = "CORE6_FILTER" // ip6tables filter/OUTPUT chain: blackhole native IPv6 when it isn't tunneled
const val ROOT_V6_FWD_CHAIN = "CORE6_FWD" // ip6tables FORWARD chain: route or reject tethered clients' native IPv6
const val ROOT_V6_PRE_CHAIN = "CORE6_PRE" // ip6tables mangle/PREROUTING chain: mark forwarded clients' IPv6 into the tun
const val ROOT_LAN_DNS = "1.1.1.1" // fallback resolver for tethered clients when no plain-IPv4 DNS is configured
const val ROOT_OOM_SCORE = "-1000" // oom_score_adj that makes the LMK never kill us
/** hev-sock5-tunnel read-write-timeout value */
const val HEVTUN_RW_TIMEOUT = "300,60"
@@ -197,13 +244,19 @@ object AppConfig {
const val GOOGLEAPIS_COM_DOMAIN = "googleapis.com"
// Android Private DNS constants
const val DNS_DNSPOD_DOMAIN = "dot.pub"
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
const val DNS_CISCO_SSE_DOMAIN = "dns.sse.cisco.com"
const val DNS_CISCO_UMBRELLA_DOMAIN = "dns.umbrella.com"
const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
const val DNS_CLOUDFLARE_ONEDOT_DNS_DOMAIN = "1dot1dot1dot1.cloudflare-dns.com"
const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
const val DNS_CLOUDFLARE_WARP_DOMAIN = "engage.cloudflareclient.com"
const val DNS_DNSPOD_DOH_DOMAIN = "doh.pub"
const val DNS_DNSPOD_DOT_DOMAIN = "dot.pub"
const val DNS_GOOGLE_DOMAIN = "dns.google"
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
const val DNS_SB_DOMAIN = "dns.sb"
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
const val DEFAULT_PORT = 443
@@ -214,13 +267,21 @@ 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_CISCO_SSE_ADDRESSES = arrayListOf("208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53")
val DNS_CISCO_UMBRELLA_ADDRESSES = arrayListOf("208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53")
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
val DNS_CLOUDFLARE_ONEDOT_DNS_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("162.159.61.8", "172.64.41.8", "2a06:98c1:52::8", "2803:f800:53::8")
val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_CLOUDFLARE_WARP_ADDRESSES = arrayListOf("162.159.192.1", "2606:4700:d0::a29f:c001")
val DNS_DNSPOD_DOH_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_DNSPOD_DOT_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
val DNS_SB_ADDRESSES = arrayListOf("45.11.45.11", "185.222.222.222", "2a09::", "2a11::")
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
//minimum list https://serverfault.com/a/304791
@@ -274,4 +335,11 @@ object AppConfig {
"Chocolate4U/Iran-v2ray-rules"
)
val BUILTIN_OUTBOUND_TAGS = setOf(
TAG_PROXY,
TAG_DIRECT,
TAG_BLOCKED,
)
val OBSERVATORY_DURATION_PATTERN = Regex("""[1-9]\d*(ms|s|m|h)""")
}
@@ -1,32 +0,0 @@
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,8 @@
package com.v2ray.ang.contracts
import android.content.Context
interface IDialerService {
fun start(context: Context, dialerAddr: String)
fun stop()
}
@@ -1,13 +0,0 @@
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,6 +1,7 @@
package com.v2ray.ang.contracts
import android.app.Service
import android.net.Network
interface ServiceControl {
/**
@@ -25,4 +26,13 @@ interface ServiceControl {
* @return True if the socket is protected, false otherwise.
*/
fun vpnProtect(socket: Int): Boolean
/**
* Declares the networks the tunnel runs on top of.
* Only meaningful for the VPN service, the other run modes have no interface to report.
*
* @param networks The upstream networks, null to let the system pick.
* @return True if the networks were accepted.
*/
fun setUnderlyingNetworks(networks: Array<Network>?): Boolean = false
}
@@ -11,7 +11,7 @@ interface Tun2SocksControl {
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
*/
fun startTun2Socks()
/**
* Stops the tun2socks process and cleans up resources.
*/
@@ -0,0 +1,273 @@
package com.v2ray.ang.core
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BalancerStrategyType
import com.v2ray.ang.enums.CoreResolvedType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
/**
* Build runtime context from the selected profile.
*
* All outbound type analysis is completed here for both the selected profile
* and routing targets. Custom profiles are returned immediately without
* entering the normal analysis flow.
*/
object CoreConfigContextBuilder {
/**
* Load one profile and produce a fully analyzed context.
*
* Null is returned only when the selected profile cannot be loaded.
*/
fun build(context: Context, guid: String): CoreConfigContext? {
val config = MmkvManager.decodeServerConfig(guid) ?: return null
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
if (config.configType == EConfigType.CUSTOM) {
return CoreConfigContext(context = context, guid = guid, isCustom = true)
}
// Step 1: Resolve the main outbound (always tag = TAG_PROXY).
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
return null
}
// Step 2: Resolve all non-builtin routing outbound tags.
val routingResolvedOutbounds = resolveRoutingOutbounds()
val resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds
val fallbackResolvedOutbounds = resolveFallbackOutbounds(resolvedOutbounds)
val routingDomainRules = collectRoutingDomainRulesForDns()
return CoreConfigContext(
context = context,
guid = guid,
resolvedOutbounds = resolvedOutbounds + fallbackResolvedOutbounds,
routingDomainRules = routingDomainRules,
)
}
/**
* Resolve one outbound target into a normalized outbound entry.
*
* Custom profiles are ignored at this stage and produce no entry.
*/
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
if (profile.configType == EConfigType.CUSTOM) {
return null
}
val (resolvedProfiles, resolvedType) = when (profile.configType) {
EConfigType.POLICYGROUP -> Pair(
resolvePolicyGroupProfiles(profile),
CoreResolvedType.POLICYGROUP,
)
EConfigType.PROXYCHAIN -> {
val chainProfiles = resolveProxyChainProfiles(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
else -> {
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
}
return CoreConfigContext.ResolvedOutbound(
tag = tag,
profile = profile,
resolvedProfiles = resolvedProfiles,
resolvedType = resolvedType,
)
}
/**
* Collect and resolve non-builtin routing targets from enabled rules.
*
* Invalid or empty targets are skipped and handled by fallback logic later.
*/
private fun resolveRoutingOutbounds(): List<CoreConfigContext.ResolvedOutbound> {
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
val resolvedOutbounds = mutableListOf<CoreConfigContext.ResolvedOutbound>()
val processedTags = mutableSetOf<String>()
try {
rulesetItems
.filter { it.enabled }
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
.distinct()
.forEach { tag ->
if (tag in processedTags) {
return@forEach
}
processedTags.add(tag)
try {
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
LogUtil.w(AppConfig.TAG, "Routing tag '$tag' has no matching profile — will fall back to proxy at routing time")
return@forEach
}
val resolvedOutbound = resolveOutbound(tag, profile) ?: run {
LogUtil.w(AppConfig.TAG, "Cannot use CUSTOM profile as routing outbound for tag '$tag', skipping")
return@forEach
}
if (resolvedOutbound.resolvedProfiles.isEmpty()) {
LogUtil.w(AppConfig.TAG, "Routing outbound '$tag' resolved to empty list, skipping")
return@forEach
}
resolvedOutbounds.add(resolvedOutbound)
LogUtil.d(AppConfig.TAG, "Resolved routing outbound: tag='$tag', type='${resolvedOutbound.resolvedType}', profiles=${resolvedOutbound.resolvedProfiles.size}")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbound for tag '$tag', skipping", e)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbounds from rulesets", e)
}
return resolvedOutbounds
}
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
try {
val serverList = MmkvManager.decodeAllServerList()
return serverList
.asSequence()
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
.filter { profile ->
val subscriptionId = config.policyGroupSubscriptionId
if (subscriptionId.isNullOrBlank()) {
true
} else {
profile.subscriptionId == subscriptionId
}
}
.filter { profile ->
val filter = config.policyGroupFilter
if (filter.isNullOrBlank()) {
true
} else {
try {
Regex(filter).containsMatchIn(profile.remarks)
} catch (_: Exception) {
profile.remarks.contains(filter)
}
}
}
.filter { it.server.isNotNullEmpty() }
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { !it.configType.isComplexType() }
.toList()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
return listOf(config)
}
}
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
if (config.proxyChainProfiles.isNullOrBlank()) {
return listOf(config)
}
try {
return config.proxyChainProfiles.orEmpty().split(",")
.asSequence()
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
.filter { it.server.isNotNullEmpty() }
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { !it.configType.isComplexType() }
.toList()
.reversed()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolve chain nodes from subscription neighbors in order: next, current, prev.
*
* When no chain is available, return a single-node result.
*/
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
if (config.subscriptionId.isEmpty()) {
return listOf(config)
}
try {
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
val resolved = mutableListOf<ProfileItem>()
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
resolved.add(config)
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
return resolved
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Collect enabled routing domain rules in original order for DNS segmentation.
*
* outbounds are normalized into three tags only: proxy / direct / block.
*/
private fun collectRoutingDomainRulesForDns(): List<CoreConfigContext.RoutingDomainRule> {
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
val result = mutableListOf<CoreConfigContext.RoutingDomainRule>()
rulesetItems
.asSequence()
.filter { it.enabled }
.filter { !it.domain.isNullOrEmpty() }
.forEach { rule ->
val normalizedOutboundTag = when (rule.outboundTag) {
AppConfig.TAG_DIRECT -> AppConfig.TAG_DIRECT
AppConfig.TAG_BLOCKED -> AppConfig.TAG_BLOCKED
else -> AppConfig.TAG_PROXY
}
result.add(
CoreConfigContext.RoutingDomainRule(
domain = rule.domain.orEmpty(),
outboundTag = normalizedOutboundTag
)
)
}
return result
}
/**
* Resolve and collect fallback outbounds from all POLICYGROUP nodes.
*
* Fallback targets must not overlap with already resolved tags or builtin tags.
*/
private fun resolveFallbackOutbounds(resolvedOutbounds: List<CoreConfigContext.ResolvedOutbound>): List<CoreConfigContext.ResolvedOutbound> {
return resolvedOutbounds
.asSequence()
.filter { it.resolvedType == CoreResolvedType.POLICYGROUP }
.filter { BalancerStrategyType.from(it.profile.policyGroupType).supportsObservatory && it.profile.policyGroupTestOutbounds != false }
.mapNotNull { it.profile.policyGroupFallbackTag }
.filter { it !in AppConfig.BUILTIN_OUTBOUND_TAGS && resolvedOutbounds.none { outbound -> outbound.tag == it } }
.distinct()
.mapNotNull { tag ->
SettingsManager.getServerViaRemarks(tag)
?.takeUnless { it.configType == EConfigType.CUSTOM || it.configType == EConfigType.POLICYGROUP }
?.let { resolveOutbound(tag, it) }
}
.toList()
}
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,8 @@
package com.v2ray.ang.handler
package com.v2ray.ang.core
import android.content.Context
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import go.Seq
import libv2ray.CoreCallbackHandler
@@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
* Thread-safe singleton wrapper for Libv2ray native methods.
* Provides initialization protection and unified API for V2Ray core operations.
*/
object V2RayNativeManager {
object CoreNativeManager {
private val initialized = AtomicBoolean(false)
/**
@@ -32,14 +32,23 @@ object V2RayNativeManager {
val assetPath = Utils.userAssetPath(context)
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
Libv2ray.initCoreEnv(assetPath, deviceId)
Log.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
LogUtil.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
LogUtil.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
initialized.set(false)
throw e
}
} else {
Log.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
}
}
fun reconcileBrowserDialer(dialerAddr: String) {
try {
Libv2ray.reconcileBrowserDialer(dialerAddr)
LogUtil.i(AppConfig.TAG, "Browser dialer reconciled successfully with address: $dialerAddr")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to reconcile browser dialer with address: $dialerAddr", e)
}
}
@@ -53,7 +62,7 @@ object V2RayNativeManager {
return try {
Libv2ray.checkVersionX()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check V2Ray version", e)
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
"Unknown"
}
}
@@ -69,7 +78,7 @@ object V2RayNativeManager {
return try {
Libv2ray.measureOutboundDelay(config, testUrl)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
-1L
}
}
@@ -84,8 +93,8 @@ object V2RayNativeManager {
return try {
Libv2ray.newCoreController(handler)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to create core controller", e)
LogUtil.e(AppConfig.TAG, "Failed to create core controller", e)
throw e
}
}
}
}
@@ -0,0 +1,692 @@
package com.v2ray.ang.core
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
/**
* Centralizes ProfileItem -> OutboundBean conversion.
* Most protocol builders mirror the previous *Fmt.toOutbound behavior.
*/
object CoreOutboundBuilder {
/** Dispatches a profile to protocol-specific outbound builder. */
fun convert(profileItem: ProfileItem): OutboundBean? {
val outbound = when (profileItem.configType) {
EConfigType.VMESS -> toOutboundVmess(profileItem)
EConfigType.SHADOWSOCKS -> toOutboundShadowsocks(profileItem)
EConfigType.SOCKS -> toOutboundSocks(profileItem)
EConfigType.VLESS -> toOutboundVless(profileItem)
EConfigType.TROJAN -> toOutboundTrojan(profileItem)
EConfigType.WIREGUARD -> toOutboundWireguard(profileItem)
EConfigType.HYSTERIA2 -> toOutboundHysteria2(profileItem)
EConfigType.HTTP -> toOutboundHttp(profileItem)
else -> null
}
outbound ?: return null
val ret = updateOutboundWithGlobalSettings(outbound)
if (!ret) return null
return outbound
}
/** Applies global outbound options (mux, protocol-specific tweaks, etc.). */
private fun updateOutboundWithGlobalSettings(outbound: OutboundBean): Boolean {
try {
var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
val protocol = outbound.protocol
if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
muxEnabled = false
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
muxEnabled = false
}
if (muxEnabled) {
outbound.mux?.enabled = true
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
outbound.mux?.concurrency = -1
}
} else {
outbound.mux?.enabled = false
outbound.mux?.concurrency = -1
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
return false
}
return true
}
/** Creates an initial outbound template for a protocol type. */
fun createInitOutbound(configType: EConfigType): OutboundBean? {
return when (configType) {
EConfigType.VMESS,
EConfigType.VLESS,
EConfigType.SHADOWSOCKS,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.TROJAN -> OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.WIREGUARD -> OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
secretKey = "",
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
)
)
EConfigType.HYSTERIA,
EConfigType.HYSTERIA2 -> OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutboundBean.OutSettingsBean(),
streamSettings = OutboundBean.StreamSettingsBean()
)
else -> null
}
}
// ── Per-protocol builders — implementations are identical to each *Fmt.toOutbound ──
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.id = profileItem.password.orEmpty()
settings.security = profileItem.method
settings.level = AppConfig.DEFAULT_LEVEL
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.id = profileItem.password.orEmpty()
settings.encryption = profileItem.method
settings.flow = profileItem.flow
settings.level = AppConfig.DEFAULT_LEVEL
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.password = profileItem.password
settings.method = profileItem.method
settings.level = AppConfig.DEFAULT_LEVEL
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.password = profileItem.password
settings.flow = profileItem.flow
settings.level = AppConfig.DEFAULT_LEVEL
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.level = AppConfig.DEFAULT_LEVEL
if (profileItem.username.isNotNullEmpty()) {
settings.user = profileItem.username.orEmpty()
settings.pass = profileItem.password.orEmpty()
}
}
return outboundBean
}
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.level = AppConfig.DEFAULT_LEVEL
if (profileItem.username.isNotNullEmpty()) {
settings.user = profileItem.username.orEmpty()
settings.pass = profileItem.password.orEmpty()
}
}
return outboundBean
}
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
val rawAddresses = profileItem.localAddress
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.ifEmpty { null }
?: listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
val addresses = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
rawAddresses
} else {
val ipv4Addresses = rawAddresses.filter { !it.contains(":") }
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
}
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = addresses
wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
}
wireguard.mtu = profileItem.mtu
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
if (!profileItem.finalMask.isNullOrBlank()) {
outboundBean?.streamSettings = OutboundBean.StreamSettingsBean()
outboundBean?.streamSettings?.let {
updateOutboundFinalMask(it, profileItem)
it.network = null
}
}
return outboundBean
}
private fun toOutboundHysteria2(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.HYSTERIA2) ?: return null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun createTcpHttpRequest(
host: String?,
path: String?
): OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean {
val requestString =
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
val request = JsonUtil.fromJson(
requestString,
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
) ?: OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
val parsedHost = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
request.headers.Host = parsedHost.ifEmpty { null }
request.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("/") }
return request
}
/**
* Configures transport settings for an outbound connection.
*
* Sets up protocol-specific transport options based on the profile settings.
*
* @param streamSettings The stream settings to configure
* @param profileItem The profile containing transport configuration
* @return The Server Name Indication (SNI) value to use, or null if not applicable
*/
fun populateTransportSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem): String? {
val transport = profileItem.network.orEmpty()
val headerType = profileItem.headerType
val host = profileItem.host
val path = profileItem.path
val seed = profileItem.seed
// val quicSecurity = profileItem.quicSecurity
// val key = profileItem.quicKey
val mode = profileItem.mode
val serviceName = profileItem.serviceName
val authority = profileItem.authority
val xhttpMode = profileItem.xhttpMode
val xhttpExtra = profileItem.xhttpExtra
val finalMask = profileItem.finalMask
var sni: String? = null
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
when (streamSettings.network) {
NetworkType.TCP.type -> {
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
val requestObj = createTcpHttpRequest(host, path)
tcpSetting.header.request = requestObj
sni = requestObj.headers.Host?.getOrNull(0)
} else {
tcpSetting.header.type = "none"
sni = host
}
streamSettings.tcpSettings = tcpSetting
}
NetworkType.KCP.type -> {
val kcpSetting = OutboundBean.StreamSettingsBean.KcpSettingsBean()
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
profileItem.kcpTti?.let { kcpSetting.tti = it }
streamSettings.kcpSettings = kcpSetting
val udpMaskList =
mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
if (!headerType.isNullOrEmpty() && headerType != "none") {
val kcpHeaderType = when {
headerType == "wechat-video" -> "wechat"
else -> headerType
}
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-legacy",
settings =
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
header = kcpHeaderType,
value = if (headerType == "dns" && !host.isNullOrEmpty()) {
host
} else {
null
}
)
)
)
}
if (seed.isNullOrEmpty()) {
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-legacy"
)
)
} else {
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-legacy",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
value = seed
)
)
)
}
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
udp = udpMaskList.toList()
)
}
NetworkType.WS.type -> {
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
wssetting.host = host.orEmpty()
sni = host
wssetting.path = path ?: "/"
streamSettings.wsSettings = wssetting
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
httpupgradeSetting.host = host.orEmpty()
sni = host
httpupgradeSetting.path = path ?: "/"
streamSettings.httpupgradeSettings = httpupgradeSetting
}
NetworkType.XHTTP.type -> {
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
xhttpSetting.host = host.orEmpty()
sni = host
xhttpSetting.path = path ?: "/"
xhttpSetting.mode = xhttpMode
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
streamSettings.xhttpSettings = xhttpSetting
}
NetworkType.H2.type, NetworkType.HTTP.type -> {
streamSettings.network = NetworkType.H2.type
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
sni = h2Setting.host.getOrNull(0)
h2Setting.path = path ?: "/"
streamSettings.httpSettings = h2Setting
}
// "quic" -> {
// val quicsetting = QuicSettingBean()
// quicsetting.security = quicSecurity ?: "none"
// quicsetting.key = key.orEmpty()
// quicsetting.header.type = headerType ?: "none"
// quicSettings = quicsetting
// }
NetworkType.GRPC.type -> {
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
grpcSetting.multiMode = mode == "multi"
grpcSetting.serviceName = serviceName.orEmpty()
grpcSetting.authority = authority.orEmpty()
grpcSetting.idle_timeout = 60
grpcSetting.health_check_timeout = 20
sni = authority
streamSettings.grpcSettings = grpcSetting
}
NetworkType.HYSTERIA.type -> {
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
version = 2,
auth = profileItem.password.orEmpty(),
)
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
)
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
if (profileItem.portHopping.isNotNullEmpty()) {
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
"30"
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
"30"
} else {
rawInterval
}
} else {
val parts = rawInterval.split('-')
if (parts.size == 2) {
val start = parts[0].trim().toIntOrNull()
val end = parts[1].trim().toIntOrNull()
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
"$minStart-$minEnd"
} else {
"30"
}
} else {
"30"
}
}
}
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
ports = profileItem.portHopping,
interval = interval
)
}
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
quicParams = quicParams
)
if (profileItem.obfsPassword.isNotNullEmpty()) {
finalmask.udp = listOf(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "salamander",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = profileItem.obfsPassword.orEmpty()
)
)
)
}
streamSettings.hysteriaSettings = hysteriaSetting
streamSettings.finalmask = finalmask
}
}
finalMask?.let {
val parsedFinalMask = JsonUtil.parseString(finalMask)
if (parsedFinalMask != null) {
streamSettings.finalmask = parsedFinalMask
} else {
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
}
}
return sni
}
/**
* Configures TLS or REALITY security settings for an outbound connection.
*
* Sets up security-related parameters like certificates, fingerprints, and SNI.
*
* @param streamSettings The stream settings to configure
* @param profileItem The profile containing security configuration
* @param sniExt An external SNI value to use if the profile doesn't specify one
*/
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
val streamSecurity = profileItem.security.orEmpty()
val allowInsecure = profileItem.insecure == true && profileItem.pinnedCA256.isNullOrEmpty()
val sni = if (profileItem.sni.isNullOrEmpty()) {
when {
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
else -> sniExt
}
} else {
profileItem.sni
}
streamSettings.security = streamSecurity.nullIfBlank()
if (streamSettings.security == null) return
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
allowInsecure = allowInsecure,
serverName = sni.nullIfBlank(),
fingerprint = profileItem.fingerPrint.nullIfBlank(),
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
echConfigList = profileItem.echConfigList.nullIfBlank(),
verifyPeerCertByName = profileItem.verifyPeerCertByName.nullIfBlank(),
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
publicKey = profileItem.publicKey.nullIfBlank(),
shortId = profileItem.shortId.nullIfBlank(),
spiderX = profileItem.spiderX.nullIfBlank(),
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
)
if (streamSettings.security == AppConfig.TLS) {
streamSettings.tlsSettings = tlsSetting
streamSettings.realitySettings = null
} else if (streamSettings.security == AppConfig.REALITY) {
streamSettings.tlsSettings = null
streamSettings.realitySettings = tlsSetting
}
if (profileItem.finalMask.isNullOrEmpty()) {
updateOutboundFragment(streamSettings)
}
}
/**
* Updates the outbound with fragment settings for traffic optimization.
*
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
*
* @param streamSettings The streamSettings object to be modified
* @return true if fragment configuration was successful, false otherwise
*/
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
try {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
return true
}
if (streamSettings.security != AppConfig.TLS
&& streamSettings.security != AppConfig.REALITY
) {
return true
}
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
return true
}
var packets =
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
if (streamSettings.security == AppConfig.REALITY
&& packets == "tlshello"
) {
packets = "1-3"
}
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "fragment",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
packets = packets,
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
?: "50-100",
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
?: "10-20",
maxSplit = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_MAXSPLIT)
?: "10"
)
)
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "noise",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
noise = listOf(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
rand = "10-20",
delay = "10-16",
)
)
)
)
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
} ?: JsonObject()
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
val current = finalMaskObj.get(scope)
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
return
}
val newArray = JsonArray()
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
if (current != null && current.isJsonArray) {
current.asJsonArray.forEach { newArray.add(it) }
}
finalMaskObj.add(scope, newArray)
}
prependMask("tcp", fragmentMask)
prependMask("udp", noiseMask)
streamSettings.finalmask = finalMaskObj
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
return false
}
return true
}
private fun getServerAddress(profileItem: ProfileItem): String {
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
return profileItem.server.orEmpty()
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
return domain
}
//Resolve and replace domain
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
if (resolvedIps.isNullOrEmpty()) {
return domain
}
return resolvedIps.first()
}
fun updateOutboundFinalMask(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem) {
val finalMask = profileItem.finalMask
finalMask?.let {
val parsedFinalMask = JsonUtil.parseString(profileItem.finalMask)
if (parsedFinalMask != null) {
streamSettings.finalmask = parsedFinalMask
} else {
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
}
}
}
}
@@ -0,0 +1,512 @@
package com.v2ray.ang.core
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Build
import android.os.ParcelFileDescriptor
import android.system.OsConstants
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.IDialerService
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.OutboundTrafficStat
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BrowserDialerMode
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.service.DialerNativeService
import com.v2ray.ang.service.DialerWebviewService
import com.v2ray.ang.service.NetworkMonitor
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.jvm.Volatile
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.ProcessFinder
import java.lang.ref.SoftReference
import java.net.InetSocketAddress
object CoreServiceManager {
private val coreController: CoreController = CoreNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null
private var processFinder: XrayProcessFinder? = null
private var browserDialer: IDialerService? = null
private var networkMonitor: NetworkMonitor? = null
@Volatile
private var isReloading = false
/** Tun descriptor the core was started with, null in the proxy only and root run modes. */
private var currentVpnInterface: ParcelFileDescriptor? = null
var serviceControl: SoftReference<ServiceControl>? = null
set(value) {
field = value
val service = value?.get()?.getService()
CoreNativeManager.initCoreEnv(service)
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
processFinder = XrayProcessFinder(service)
coreController.registerProcessFinder(processFinder)
}
}
/**
* 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()
/**
* 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 (isRunning()) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false
}
val service = getService()
if (service == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
return false
}
try {
doStartCoreLoop(service, vpnInterface)
return true
} catch (e: Exception) {
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
LogUtil.e(AppConfig.TAG, "StartCore-Manager: $message", e)
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
NotificationManager.cancelNotification()
return false
}
}
@Throws(Exception::class)
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
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())
currentVpnInterface = vpnInterface
launchCore(service, vpnInterface)
startNetworkMonitor(service)
}
@Throws(Exception::class)
private fun launchCore(service: Service, vpnInterface: ParcelFileDescriptor?, isReload: Boolean = false) {
val guid = MmkvManager.getSelectServer() ?: error("No server selected")
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = CoreConfigManager.getV2rayConfig(service, guid)
LogUtil.d(AppConfig.TAG, result.content)
if (!result.status) {
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
}
currentConfig = config
var tunFd = vpnInterface?.fd ?: 0
val dialerMode = BrowserDialerMode.from(config.browserDialerMode)
val dialerAddr = if (dialerMode != null) {
"127.0.0.1:${Utils.findRandomFreePort()}"
} else {
""
}
if (SettingsManager.isUsingHevTun()) {
tunFd = 0
}
NotificationManager.showNotification(currentConfig)
if (dialerAddr.isNotNullEmpty()) {
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
}
coreController.startLoop(result.content, tunFd)
if (!isRunning()) {
error("Core failed to start")
}
if (browserDialer != null) {
browserDialer!!.stop()
browserDialer = null
}
when (dialerMode) {
BrowserDialerMode.OKHTTP -> {
browserDialer = DialerNativeService()
browserDialer!!.start(service, dialerAddr)
}
BrowserDialerMode.WEBVIEW -> {
browserDialer = DialerWebviewService()
browserDialer!!.start(service, dialerAddr)
}
else -> {}
}
if (!isReload) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
}
NotificationManager.startSpeedNotification()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
}
/**
* Stops the V2Ray core service.
* Unregisters broadcast receivers, stops notifications, and shuts down plugins.
* @return True if the core was stopped successfully, false otherwise.
*/
fun stopCoreLoop(): Boolean {
val service = getService() ?: return false
networkMonitor?.unregister()
networkMonitor = null
currentVpnInterface = null
if (isRunning()) {
CoroutineScope(Dispatchers.IO).launch {
try {
coreController.stopLoop()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
}
}
}
// Close existing browser dialer
CoreNativeManager.reconcileBrowserDialer("")
if (browserDialer != null) {
browserDialer!!.stop()
browserDialer = null
}
MessageHelper.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
}
/**
* Subscribes to upstream network changes for whichever run mode is active.
* All three services share this manager, so the tunnel recovers from a handover in proxy only
* and root mode as well, not just behind the VPN interface.
*/
private fun startNetworkMonitor(service: Service) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
if (networkMonitor != null) return
val connectivity = service.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
networkMonitor = NetworkMonitor(
connectivity = connectivity,
onUnderlyingNetworksChanged = { networks -> serviceControl?.get()?.setUnderlyingNetworks(networks) },
onHandover = { reloadCore() },
).also { it.register() }
}
/**
* Restarts the core in place after the upstream network changed: the service, the notification
* and the VPN interface all stay up, so nothing of this is visible.
*
* The config is rebuilt on purpose, outbound server domains are resolved while building it and
* an address resolved on a network that is gone can be unusable on the new one.
*
* @return True if the core is running again.
*/
private fun reloadCore(): Boolean {
if (isReloading) return false
val service = getService() ?: return false
if (!isRunning()) return false
return try {
val tunFd = tunFdForCore()
isReloading = true
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
coreController.stopLoop()
launchCore(service, tunFd, isReload = true)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload finished")
true
} catch (e: Exception) {
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to reload core: $message", e)
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
false
} finally {
isReloading = false
}
}
/**
* Returns the tun descriptor to hand to the core on a reload.
*
* With hev-socks5-tunnel the core is started without a tun and never touches the descriptor.
* Otherwise it closes the one it was given when it stops, which would take the VPN interface
* down, so it gets a duplicate to close instead.
*/
private fun tunFdForCore(): ParcelFileDescriptor? {
val vpnInterface = currentVpnInterface ?: return null
return try {
if (SettingsManager.isUsingHevTun()) vpnInterface else vpnInterface.dup()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to duplicate VPN interface", e)
throw e
}
}
/**
* Queries and resets all outbound traffic counters in one core call.
* Go side format: tag,direction,value;tag,direction,value;
*/
fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> {
// The stats manager is gone once the core stops, querying it then reaches into freed state.
if (!isRunning()) return emptyList()
val payload = coreController.queryAllOutboundTrafficStats()
val result = ArrayList<OutboundTrafficStat>()
payload.split(';').forEach { entry ->
if (entry.isBlank()) return@forEach
val parts = entry.split(',', limit = 3)
if (parts.size != 3) return@forEach
val value = parts[2].toLongOrNull() ?: return@forEach
result.add(
OutboundTrafficStat(
tag = parts[0],
direction = parts[1],
value = value,
)
)
}
// LogUtil.d(AppConfig.TAG, "Queried outbound traffic stats: $result")
return result
}
/**
* Measures the connection delay for the current V2Ray configuration.
* Tests with primary URL first, then falls back to alternative URL if needed.
* Also fetches remote IP information if the delay test was successful.
*/
private fun measureV2rayDelay() {
if (!isRunning()) {
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)
}
MessageHelper.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 ->
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
}
}
}
}
/**
* Gets the current service instance.
* @return The current service instance, or null if not available.
*/
private fun getService(): Service? {
return serviceControl?.get()?.getService()
}
/**
* Core callback handler implementation for handling V2Ray core events.
* Handles startup, shutdown, socket protection, and status emission.
*/
private class CoreCallback : CoreCallbackHandler {
/**
* Called when V2Ray core starts up.
* @return 0 for success, any other value for failure.
*/
override fun startup(): Long {
return 0
}
/**
* Called when V2Ray core shuts down.
* @return 0 for success, any other value for failure.
*/
override fun shutdown(): Long {
val serviceControl = serviceControl?.get() ?: return -1
return try {
serviceControl.stopService()
0
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
-1
}
}
/**
* Called when V2Ray core emits status information.
* @param l Status code.
* @param s Status message.
* @return Always returns 0.
*/
override fun onEmitStatus(l: Long, s: String?): Long {
return 0
}
}
/**
* Process finder implementation for Xray core.
* Uses ConnectivityManager to find the owning UID of a connection based on network parameters.
*/
private class XrayProcessFinder(context: Context) : ProcessFinder {
private val cm: ConnectivityManager? = context.getSystemService(ConnectivityManager::class.java)
override fun findProcessByConnection(network: String, srcIP: String, srcPort: Long, destIP: String, destPort: Long): Long {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return -1L
if (cm == null) return -1L
val proto = when (network) {
"tcp" -> OsConstants.IPPROTO_TCP
"udp" -> OsConstants.IPPROTO_UDP
else -> return -1L
}
if (destIP.isBlank() || destPort == 0L) {
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to :$destPort, (no dest)")
return -1L
}
return try {
val uid = cm.getConnectionOwnerUid(
proto,
InetSocketAddress(srcIP, srcPort.toInt()),
InetSocketAddress(destIP, destPort.toInt())
).toLong()
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid")
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
uid
} catch (_: Exception) {
-1L
}
}
}
/**
* Broadcast receiver for handling messages sent to the service.
* Handles registration, service control, and screen events.
*/
private class ReceiveMessageHandler : BroadcastReceiver() {
/**
* Handles received broadcast messages.
* Processes service control messages and screen state changes.
* @param ctx The context in which the receiver is running.
* @param intent The intent being received.
*/
override fun onReceive(ctx: Context?, intent: Intent?) {
val serviceControl = serviceControl?.get() ?: return
when (intent?.getIntExtra("key", 0)) {
AppConfig.MSG_REGISTER_CLIENT -> {
if (isRunning()) {
MessageHelper.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
} else {
MessageHelper.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)
LauncherManager.startService(serviceControl.getService())
}
AppConfig.MSG_MEASURE_DELAY -> {
measureV2rayDelay()
}
}
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification()
}
Intent.ACTION_SCREEN_ON -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification()
}
}
}
}
}
@@ -0,0 +1,129 @@
package com.v2ray.ang.core
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.root.RootManager
import com.v2ray.ang.service.CoreProxyOnlyService
import com.v2ray.ang.service.CoreRootService
import com.v2ray.ang.service.CoreVpnService
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
object LauncherManager {
fun startServiceFromToggle(context: Context): Boolean {
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
context.toast(R.string.app_tile_first_use)
return false
}
try {
startContextService(context)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "LauncherManager: ${e.message}", e)
context.toast(e.message ?: e.javaClass.simpleName)
return false
}
return true
}
fun startService(context: Context, guid: String? = null) {
LogUtil.i(AppConfig.TAG, "LauncherManager: startService from ${context::class.java.simpleName}")
if (guid != null) {
MmkvManager.setSelectServer(guid)
}
try {
startContextService(context)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "LauncherManager: ${e.message}", e)
context.toast(e.message ?: e.javaClass.simpleName)
}
}
fun stopService(context: Context) {
//context.toast(R.string.toast_services_stop)
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
}
@Throws(Exception::class)
private fun startContextService(context: Context) {
// Note: isRunning check is removed here to avoid loading Native libraries in the UI process.
// The check is performed in CoreServiceManager when the service starts in the daemon process.
val guid = MmkvManager.getSelectServer()
?: run {
LogUtil.e(AppConfig.TAG, "LauncherManager: No server selected")
error(context.getString(R.string.app_tile_first_use))
}
val config = MmkvManager.decodeServerConfig(guid)
?: run {
LogUtil.e(AppConfig.TAG, "LauncherManager: Failed to decode server config")
error(context.getString(R.string.toast_config_file_invalid))
}
if (!config.configType.isComplexType()
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) {
LogUtil.e(AppConfig.TAG, "LauncherManager: Invalid server configuration")
error(context.getString(R.string.toast_config_file_invalid))
}
SettingsManager.refreshRuntimeSocksPort()
if (config.insecure == true && config.pinnedCA256.isNullOrEmpty()) {
context.toastError(R.string.toast_allow_insecure_deprecated)
Utils.setClipboard(context, context.getString(R.string.toast_allow_insecure_deprecated))
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
context.toast(R.string.toast_warning_pref_proxysharing_short)
} else {
context.toast(R.string.toast_services_start)
}
val isRootMode = SettingsManager.isRootMode()
if (isRootMode && !RootManager.isRootAvailable()) {
LogUtil.e(AppConfig.TAG, "LauncherManager: root mode requires root but none available")
error(context.getString(R.string.toast_root_required))
}
val intent = if (isRootMode) {
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting Root service")
Intent(context.applicationContext, CoreRootService::class.java)
} else if (SettingsManager.isVpnMode()) {
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting VPN service")
Intent(context.applicationContext, CoreVpnService::class.java)
} else {
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting Proxy service")
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: SecurityException) {
LogUtil.e(AppConfig.TAG, "LauncherManager: Missing permission to start foreground service", e)
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
} catch (e: RuntimeException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
) {
LogUtil.e(AppConfig.TAG, "LauncherManager: Foreground service start not allowed", e)
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
}
throw e
}
}
}
@@ -0,0 +1,13 @@
package com.v2ray.ang.dto
data class CertSha256Request(
val address: String = "",
val port: Int = 443,
val serverName: String? = null,
val timeoutMs: Long = 5000L,
)
data class CertSha256Result(
val sha256: String = "",
val error: String = "",
)
@@ -4,5 +4,6 @@ data class ConfigResult(
var status: Boolean,
var guid: String? = null,
var content: String = "",
var errorMessage: String = "",
)
@@ -0,0 +1,25 @@
package com.v2ray.ang.dto
import android.content.Context
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.CoreResolvedType
data class CoreConfigContext(
val context: Context,
val guid: String,
val isCustom: Boolean = false,
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
val routingDomainRules: List<RoutingDomainRule> = emptyList(),
) {
data class ResolvedOutbound(
val tag: String,
val profile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
)
data class RoutingDomainRule(
val domain: List<String>,
val outboundTag: String,
)
}
@@ -0,0 +1,7 @@
package com.v2ray.ang.dto
data class LocateTarget(
val groupId: String,
val groupIndex: Int,
val itemPosition: Int
)
@@ -0,0 +1,7 @@
package com.v2ray.ang.dto
data class OutboundTrafficStat(
val tag: String,
val direction: String,
val value: Long,
)
@@ -1,130 +0,0 @@
package com.v2ray.ang.dto
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.AppConfig.PORT_SOCKS
import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils
data class ProfileItem(
val configVersion: Int = 4,
val configType: EConfigType,
var subscriptionId: String = "",
var addedTime: Long = System.currentTimeMillis(),
var remarks: String = "",
var description: String? = null,
var server: String? = null,
var serverPort: String? = null,
var password: String? = null,
var method: String? = null,
var flow: String? = null,
var username: String? = null,
var network: String? = null,
var headerType: String? = null,
var host: String? = null,
var path: String? = null,
var seed: String? = null,
var quicSecurity: String? = null,
var quicKey: String? = null,
var mode: String? = null,
var serviceName: String? = null,
var authority: String? = null,
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var 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,
var localAddress: String? = null,
var reserved: String? = null,
var mtu: Int? = null,
var obfsPassword: String? = null,
var portHopping: String? = null,
var portHoppingInterval: String? = null,
var pinSHA256: String? = null,
var bandwidthDown: String? = null,
var bandwidthUp: String? = null,
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
) {
companion object {
fun create(configType: EConfigType): ProfileItem {
return ProfileItem(configType = configType)
}
}
fun getAllOutboundTags(): MutableList<String> {
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
}
fun getServerAddressAndPort(): String {
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
return "$LOOPBACK:$PORT_SOCKS"
}
return Utils.getIpv6Address(server) + ":" + serverPort
}
override fun equals(other: Any?): Boolean {
if (other == null) return false
val obj = other as ProfileItem
return (this.server == obj.server
&& this.serverPort == obj.serverPort
&& this.password == obj.password
&& this.method == obj.method
&& this.flow == obj.flow
&& this.username == obj.username
&& this.network == obj.network
&& this.headerType == obj.headerType
&& this.host == obj.host
&& this.path == obj.path
&& this.seed == obj.seed
&& this.quicSecurity == obj.quicSecurity
&& this.quicKey == obj.quicKey
&& this.mode == obj.mode
&& this.serviceName == obj.serviceName
&& this.authority == obj.authority
&& this.xhttpMode == obj.xhttpMode
&& this.security == obj.security
&& this.sni == obj.sni
&& this.alpn == obj.alpn
&& this.fingerPrint == obj.fingerPrint
&& this.publicKey == obj.publicKey
&& this.shortId == obj.shortId
&& this.secretKey == obj.secretKey
&& this.localAddress == obj.localAddress
&& this.reserved == obj.reserved
&& this.mtu == obj.mtu
&& this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinnedCA256 == obj.pinnedCA256
)
}
}
@@ -0,0 +1,14 @@
package com.v2ray.ang.dto
sealed class RealPingEvent {
/** Periodic progress update while the batch is still running. */
data class Progress(val text: String) : RealPingEvent()
/** A single server result is available. */
data class Result(val guid: String, val delayMillis: Long) : RealPingEvent()
/** The entire batch has finished or been cancelled. */
data class Finish(val status: String) : RealPingEvent()
}
@@ -1,6 +0,0 @@
package com.v2ray.ang.dto
data class ServersCache(
val guid: String,
val profile: ProfileItem
)
@@ -0,0 +1,9 @@
package com.v2ray.ang.dto
import java.io.Serializable
data class SubscriptionUpdateMessage(
val key: Int,
val forcedUpdate: Boolean,
val subIds: List<String> = listOf()
) : Serializable
@@ -5,6 +5,7 @@ import java.io.Serializable
data class TestServiceMessage(
val key: Int,
val subscriptionId: String = "",
val serverGuids: List<String> = emptyList()
val serverGuids: List<String> = emptyList(),
val onlyTcp: Boolean = false
) : Serializable
@@ -0,0 +1,11 @@
package com.v2ray.ang.dto
data class UrlContentRequest(
val url: String?,
val timeout: Int = 15000,
val httpPort: Int = 0,
val proxyUsername: String? = null,
val proxyPassword: String? = null,
val userAgent: String? = null,
val requestHeaders: String? = null
)
@@ -31,7 +31,7 @@ data class V2rayConfig(
data class InboundBean(
var tag: String,
var port: Int,
var port: Int?,
var protocol: String,
var listen: String? = null,
var settings: InSettingsBean? = null,
@@ -44,10 +44,16 @@ data class V2rayConfig(
var auth: String? = null,
var udp: Boolean? = null,
var userLevel: Int? = null,
var accounts: List<SocksAccountBean>? = null,
var name: String? = null,
@SerializedName("MTU")
var mtu: Int? = null
)
) {
data class SocksAccountBean(
var user: String = "",
var pass: String = ""
)
}
data class SniffingBean(
var enabled: Boolean,
@@ -62,85 +68,38 @@ data class V2rayConfig(
var protocol: String,
var settings: OutSettingsBean? = null,
var streamSettings: StreamSettingsBean? = null,
val proxySettings: Any? = null,
val sendThrough: String? = null,
var mux: MuxBean? = MuxBean(false)
) {
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,
/*Common */
var address: Any? = null,
var port: Int? = null,
/*Freedom*/
var domainStrategy: String? = null,
val redirect: String? = null,
val userLevel: Int? = null,
/*Loopback*/
val inboundTag: String? = null,
var level: Int? = null,
var email: String? = null,
/*HTTP/SOCKS*/
var user: String? = null,
var pass: String? = null,
var headers: Map<String, String>? = null,
/*VMess/VLESS*/
var id: String? = null,
var security: String? = null,
var encryption: String? = null,
/*VLESS*/
var flow: String? = null,
/*Trojan/Shadowsocks*/
var password: String? = null,
/*Shadowsocks*/
var method: String? = null,
/*Hysteria/Hysteria2*/
var version: Int? = null,
/*Wireguard*/
var secretKey: String? = null,
val peers: List<WireGuardBean>? = null,
var reserved: List<Int>? = null,
var mtu: Int? = null,
var obfsPassword: String? = null,
var version: Int? = null,
var domainStrategy: String? = null,
) {
data class VnextBean(
var address: String = "",
var port: Int = AppConfig.DEFAULT_PORT,
var users: List<UsersBean>
) {
data class UsersBean(
var id: String = "",
var alterId: Int? = null,
var security: String? = null,
var level: Int = AppConfig.DEFAULT_LEVEL,
var encryption: String? = null,
var flow: String? = null
)
}
data class 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,
var ota: Boolean = false,
var password: String? = null,
var port: Int = AppConfig.DEFAULT_PORT,
var level: Int = AppConfig.DEFAULT_LEVEL,
val email: String? = null,
var flow: String? = null,
val ivCheck: Boolean? = null,
var users: List<SocksUsersBean>? = null
) {
data class SocksUsersBean(
var user: String = "",
var pass: String = "",
var level: Int = AppConfig.DEFAULT_LEVEL
)
}
data class Response(var type: String)
data class WireGuardBean(
var publicKey: String = "",
var preSharedKey: String? = null,
@@ -149,7 +108,7 @@ data class V2rayConfig(
}
data class StreamSettingsBean(
var network: String = AppConfig.DEFAULT_NETWORK,
var network: String? = AppConfig.DEFAULT_NETWORK,
var security: String? = null,
var tcpSettings: TcpSettingsBean? = null,
var kcpSettings: KcpSettingsBean? = null,
@@ -162,7 +121,7 @@ data class V2rayConfig(
var realitySettings: TlsSettingsBean? = null,
var grpcSettings: GrpcSettingsBean? = null,
var hysteriaSettings: HysteriaSettingsBean? = null,
var finalmask: FinalMaskBean? = null,
var finalmask: Any? = null,
val dsSettings: Any? = null,
var sockopt: SockoptBean? = null
) {
@@ -189,7 +148,7 @@ data class V2rayConfig(
@SerializedName("Accept-Encoding")
val acceptEncoding: List<String>? = null,
val Connection: List<String>? = null,
val Pragma: String? = null
val Pragma: Any? = null
)
}
}
@@ -207,13 +166,12 @@ data class V2rayConfig(
data class WsSettingsBean(
var path: String? = null,
var headers: HeadersBean = HeadersBean(),
var host: String? = null,
var headers: Map<String, String>? = null,
val maxEarlyData: Int? = null,
val useBrowserForwarding: Boolean? = null,
val acceptProxyProtocol: Boolean? = null
) {
data class HeadersBean(var Host: String = "")
}
)
data class HttpupgradeSettingsBean(
var path: String? = null,
@@ -264,10 +222,9 @@ data class V2rayConfig(
val disableSystemRoot: Boolean? = null,
val enableSessionResumption: Boolean? = null,
var echConfigList: String? = null,
var echForceQuery: String? = null,
var verifyPeerCertByName: String? = null,
var pinnedPeerCertSha256: String? = null,
// REALITY settings
val show: Boolean = false,
var publicKey: String? = null,
var shortId: String? = null,
var spiderX: String? = null,
@@ -292,28 +249,52 @@ data class V2rayConfig(
data class HysteriaSettingsBean(
var version: Int,
var auth: String? = null,
var up: String? = null,
var down: String? = null,
var udphop: HysteriaUdpHopBean? = null
) {
data class HysteriaUdpHopBean(
var port: String? = null,
var interval: Int? = null
)
}
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 udp: List<MaskBean>? = null,
var quicParams: QuicParamsBean? = null
) {
data class MaskBean(
var type: String,
var settings: MaskSettingsBean? = null
) {
data class MaskSettingsBean(
var password: String? = null,
var domain: String? = null
val password: String? = null,
val header: String? = null,
val value: String? = null,
// fragment
val packets: String? = null,
val length: String? = null,
val delay: String? = null,
val maxSplit: String? = null,
// noise
val reset: Int? = null,
val noise: List<NoiseMaskBean>? = null
) {
data class NoiseMaskBean(
val rand: String? = null,
// val randRange: String? = null,
// val type: String? = null,
// val packet: String? = null,
val delay: String? = null,
)
}
}
data class QuicParamsBean(
var congestion: String? = null,
var brutalUp: String? = null,
var brutalDown: String? = null,
var udpHop: UdpHopBean? = null,
) {
// Nested data class for the udpHop JSON object
data class UdpHopBean(
var ports: String? = null,
var interval: String? = null
)
}
}
@@ -327,45 +308,19 @@ data class V2rayConfig(
)
fun getServerAddress(): String? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) {
return settings?.vnext?.first()?.address
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
) {
return settings?.servers?.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 if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
} else {
settings?.address as? String
}
return null
}
fun getServerPort(): Int? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) {
return settings?.vnext?.first()?.port
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
) {
return settings?.servers?.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 if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toIntOrNull()
} else {
settings?.port
}
return null
}
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
@@ -387,6 +342,7 @@ data class V2rayConfig(
val clientIp: String? = null,
val disableCache: Boolean? = null,
val queryStrategy: String? = null,
val enableParallelQuery: Boolean? = null,
val tag: String? = null
) {
data class ServersBean(
@@ -409,8 +365,9 @@ data class V2rayConfig(
data class RulesBean(
var type: String = "field",
var ip: ArrayList<String>? = null,
var domain: ArrayList<String>? = null,
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,
@@ -480,6 +437,7 @@ data class V2rayConfig(
data class PingConfigObject(
val destination: String,
val connectivity: String? = null,
val httpMethod: String? = null,
val interval: String,
val sampling: Int,
val timeout: String? = null
@@ -0,0 +1,168 @@
package com.v2ray.ang.dto
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BalancerStrategyType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
@Suppress("PropertyName")
data class V2rayNShareItem(
// val IndexId: String?,
val ConfigType: Int?,
// val CoreType: Int?,
val ConfigVersion: Int?,
val Subid: String?,
val IsSub: Boolean?,
val PreSocksPort: Int?,
// val DisplayLog: Boolean?,
val Remarks: String?,
val Address: String?,
val Port: Int?,
val Password: String?,
val Username: String?,
val Network: String?,
val StreamSecurity: String?,
val AllowInsecure: String?,
val Sni: String?,
val Alpn: String?,
val Fingerprint: String?,
val PublicKey: String?,
val ShortId: String?,
val SpiderX: String?,
val Mldsa65Verify: String?,
// val MuxEnabled: Boolean?,
// val Cert: String?,
val CertSha: String?,
val EchConfigList: String?,
val VerifyPeerCertByName: String?,
val Finalmask: String?,
val ProtoExtraObj: V2rayNProtocolExtraShareItem?,
val TransportExtraObj: V2rayNTransportExtraShareItem?,
) {
data class V2rayNProtocolExtraShareItem(
val AlterId: Int?,
val VmessSecurity: String?,
val Flow: String?,
val VlessEncryption: String?,
val SsMethod: String?,
val WgPublicKey: String?,
val WgPresharedKey: String?,
val WgInterfaceAddress: String?,
val WgReserved: String?,
val WgMtu: Int?,
val SalamanderPass: String?,
val UpMbps: Int?,
val DownMbps: Int?,
val Ports: String?,
val HopInterval: String?,
val GroupType: String?,
val ChildItems: String?,
val SubChildItems: String?,
val Filter: String?,
val MultipleLoad: Int?,
)
data class V2rayNTransportExtraShareItem(
val RawHeaderType: String?,
val Host: String?,
val Path: String?,
val XhttpMode: String?,
val XhttpExtra: String?,
val GrpcAuthority: String?,
val GrpcServiceName: String?,
val GrpcMode: String?,
val KcpHeaderType: String?,
val KcpSeed: String?,
val KcpMtu: Int?,
)
fun toProfileItem(): ProfileItem {
val configType = when (ConfigType) {
1 -> EConfigType.VMESS
2 -> EConfigType.CUSTOM
3 -> EConfigType.SHADOWSOCKS
4 -> EConfigType.SOCKS
5 -> EConfigType.VLESS
6 -> EConfigType.TROJAN
7 -> EConfigType.HYSTERIA2
// 8 -> EConfigType.TUIC
9 -> EConfigType.WIREGUARD
10 -> EConfigType.HTTP
101 -> EConfigType.POLICYGROUP
102 -> EConfigType.PROXYCHAIN
else -> error("Unknown ConfigType: $ConfigType")
}
val network = if (configType == EConfigType.HYSTERIA2) "hysteria" else
when (Network) {
"raw" -> "tcp"
else -> NetworkType.fromString(Network).type
}
val profile = ProfileItem(
configType = configType,
remarks = Remarks.orEmpty(),
server = Address.orEmpty(),
serverPort = Port.toString(),
password = Password.orEmpty(),
method = when (configType) {
EConfigType.VMESS -> ProtoExtraObj?.VmessSecurity.orEmpty()
EConfigType.SHADOWSOCKS -> ProtoExtraObj?.SsMethod.orEmpty()
EConfigType.VLESS -> ProtoExtraObj?.VlessEncryption.orEmpty()
else -> null
},
flow = ProtoExtraObj?.Flow.orEmpty(),
username = Username.orEmpty(),
network = network,
headerType = when (network) {
NetworkType.TCP.type -> TransportExtraObj?.RawHeaderType
NetworkType.KCP.type -> TransportExtraObj?.KcpHeaderType
else -> null
},
host = TransportExtraObj?.Host,
path = TransportExtraObj?.Path,
seed = TransportExtraObj?.KcpSeed,
kcpMtu = TransportExtraObj?.KcpMtu,
mode = TransportExtraObj?.GrpcMode,
serviceName = TransportExtraObj?.GrpcServiceName,
authority = TransportExtraObj?.GrpcAuthority,
xhttpMode = TransportExtraObj?.XhttpMode,
xhttpExtra = TransportExtraObj?.XhttpExtra,
finalMask = Finalmask,
security = StreamSecurity,
sni = Sni,
alpn = Alpn,
fingerPrint = Fingerprint,
insecure = AllowInsecure?.toBoolean(),
echConfigList = EchConfigList,
verifyPeerCertByName = VerifyPeerCertByName,
pinnedCA256 = CertSha,
publicKey = PublicKey,
shortId = ShortId,
spiderX = SpiderX,
mldsa65Verify = Mldsa65Verify,
secretKey = if (configType == EConfigType.WIREGUARD) Password else null,
preSharedKey = ProtoExtraObj?.WgPresharedKey,
localAddress = ProtoExtraObj?.WgInterfaceAddress,
reserved = ProtoExtraObj?.WgReserved,
mtu = ProtoExtraObj?.WgMtu,
obfsPassword = ProtoExtraObj?.SalamanderPass,
portHopping = ProtoExtraObj?.Ports,
portHoppingInterval = ProtoExtraObj?.HopInterval,
bandwidthDown = ProtoExtraObj?.DownMbps?.takeIf { it > 0 }?.let { "${it}Mbps" },
bandwidthUp = ProtoExtraObj?.UpMbps?.takeIf { it > 0 }?.let { "${it}Mbps" },
policyGroupType = when (ProtoExtraObj?.MultipleLoad) {
2 -> BalancerStrategyType.RANDOM.policyGroupType
3 -> BalancerStrategyType.ROUND_ROBIN.policyGroupType
4 -> BalancerStrategyType.LEAST_LOAD.policyGroupType
else -> BalancerStrategyType.LEAST_PING.policyGroupType
},
// NOTE: not safe, suggest converting and rewriting
// policyGroupSubscriptionId = ProtoExtraObj?.SubChildItems,
policyGroupSubscriptionId = if (ProtoExtraObj?.SubChildItems == "self") "self" else null,
policyGroupFilter = ProtoExtraObj?.Filter,
// NOTE: proxyChainProfiles stores remarks, not IndexId
// proxyChainProfiles = ProtoExtraObj?.ChildItems,
)
// profile.description =
return profile
}
}
@@ -16,5 +16,7 @@ data class VmessQRCode(
var sni: String = "",
var alpn: String = "",
var fp: String = "",
var insecure: String = ""
var insecure: String = "",
var vcn: String = "",
var pcs: String = ""
)
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class AssetUrlCache(
val guid: String,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class AssetUrlItem(
var remarks: String = "",
@@ -0,0 +1,112 @@
package com.v2ray.ang.dto.entities
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils
data class ProfileItem(
val configVersion: Int = 4,
val configType: EConfigType,
var subscriptionId: String = "",
var addedTime: Long = System.currentTimeMillis(),
var remarks: String = "",
var description: String? = null,
var server: String? = null,
var serverPort: String? = null,
var password: String? = null,
var method: String? = null,
var flow: String? = null,
var username: String? = null,
var network: String? = null,
var headerType: String? = null,
var host: String? = null,
var path: String? = null,
var seed: String? = null,
var kcpMtu: Int? = null,
var kcpTti: Int? = null,
var quicSecurity: String? = null,
var quicKey: String? = null,
var mode: String? = null,
var serviceName: String? = null,
var authority: String? = null,
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null,
var sni: String? = null,
var alpn: String? = null,
var fingerPrint: String? = null,
var insecure: Boolean? = null,
var echConfigList: String? = null,
var verifyPeerCertByName: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null,
var shortId: String? = null,
var spiderX: String? = null,
var mldsa65Verify: String? = null,
var secretKey: String? = null,
var preSharedKey: String? = null,
var localAddress: String? = null,
var reserved: String? = null,
var mtu: Int? = null,
var obfsPassword: String? = null,
var portHopping: String? = null,
var portHoppingInterval: String? = null,
@Deprecated("Use pinnedCA256")
var pinSHA256: String? = null,
var bandwidthDown: String? = null,
var bandwidthUp: String? = null,
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
var policyGroupTestOutbounds: Boolean? = null,
var policyGroupFallbackTag: String? = null,
var proxyChainProfiles: String? = null,
var browserDialerMode: String? = null,
) {
companion object {
fun create(configType: EConfigType): ProfileItem =
ProfileItem(configType = configType)
}
fun getServerAddressAndPort(): String {
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
}
return "${Utils.getIpv6Address(server)}:$serverPort"
}
/**
* Dedicated identity for "remove duplicate configurations".
*
* Ignores metadata that does not affect connection:
* - configVersion
* - subscriptionId
* - addedTime
* - remarks
* - description
*
* All other fields, including configType, are included in the comparison.
*
* Returns a copy; the caller must not modify it further.
*/
fun duplicateIdentity(): ProfileItem =
copy(
configVersion = 0,
subscriptionId = "",
addedTime = 0L,
remarks = "",
description = null
)
}
@@ -1,9 +1,11 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class RulesetItem(
var id: String = "",
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,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
fun getTestDelayString(): String {
@@ -7,4 +7,4 @@ data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
}
return testDelayMillis.toString() + "ms"
}
}
}
@@ -0,0 +1,8 @@
package com.v2ray.ang.dto.entities
data class ServersCache(
val guid: String,
val profile: ProfileItem,
val testDelayMillis: Long = 0L,
val testDelayString: String = "",
)
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class SubscriptionCache(
val guid: String,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class SubscriptionItem(
var remarks: String = "",
@@ -7,11 +7,12 @@ data class SubscriptionItem(
val addedTime: Long = System.currentTimeMillis(),
var lastUpdated: Long = -1,
var autoUpdate: Boolean = false,
val updateInterval: Int? = null,
var updateInterval: Long = 1440, // in minutes, default to 24 hours
var prevProfile: String? = null,
var nextProfile: String? = null,
var filter: String? = null,
var allowInsecureUrl: Boolean = false,
var userAgent: String? = null,
var requestHeaders: String? = null,
)
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class WebDavConfig(
val baseUrl: String,
@@ -6,4 +6,4 @@ data class WebDavConfig(
val password: String? = null,
val remoteBasePath: String = "/",
val timeoutSeconds: Long = 30
)
)
@@ -0,0 +1,28 @@
package com.v2ray.ang.enums
/**
* Load-balancing strategy types supported by xray balancer.
*
* @param policyGroupType The string value written into the xray config JSON.
* @param policyGroupTypeValue The numeric string stored in [com.v2ray.ang.dto.entities.ProfileItem.policyGroupType].
* @param requiresBurstObservatory Whether this strategy needs a burstObservatory (leastLoad).
* @param requiresObservatory Whether this strategy needs an observatory (leastPing).
* @param supportsObservatory Whether an observatory can health-filter this strategy.
*/
enum class BalancerStrategyType(
val policyGroupType: String,
val policyGroupTypeValue: String,
val requiresBurstObservatory: Boolean = false,
val requiresObservatory: Boolean = false,
val supportsObservatory: Boolean = false,
) {
LEAST_LOAD("leastLoad", "1", requiresBurstObservatory = true),
RANDOM("random", "2", supportsObservatory = true),
ROUND_ROBIN("roundRobin", "3", supportsObservatory = true),
LEAST_PING("leastPing", "", requiresObservatory = true); // default / else
companion object {
fun from(policyGroupType: String?): BalancerStrategyType =
entries.firstOrNull { it.policyGroupTypeValue == policyGroupType } ?: LEAST_PING
}
}
@@ -0,0 +1,10 @@
package com.v2ray.ang.enums
enum class BrowserDialerMode(val value: String) {
OKHTTP("OkHttp"),
WEBVIEW("WebView");
companion object {
fun from(value: String?): BrowserDialerMode? = entries.find { it.value == value }
}
}
@@ -0,0 +1,8 @@
package com.v2ray.ang.enums
/** Runtime type used during config assembly only. */
enum class CoreResolvedType {
NORMAL,
POLICYGROUP,
PROXYCHAIN,
}
@@ -15,7 +15,8 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
HYSTERIA2(9, AppConfig.HYSTERIA2),
HYSTERIA(900, AppConfig.HYSTERIA),
HTTP(10, AppConfig.HTTP),
POLICYGROUP (101, AppConfig.CUSTOM);
POLICYGROUP(101, AppConfig.CUSTOM),
PROXYCHAIN(102, AppConfig.CUSTOM);
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
@@ -0,0 +1,22 @@
package com.v2ray.ang.enums
/**
* Enum defining different notification channels.
* Each channel has a unique channelId, notificationId, and display name.
*/
enum class NotificationChannelType(
val channelId: String,
val channelName: String,
val notificationId: Int
) {
SUBSCRIPTION_UPDATE(
channelId = "subscription_update_channel",
channelName = "Subscription Update Service",
notificationId = 13
),
CORE_TEST(
channelId = "core_test_channel",
channelName = "Core Test Service",
notificationId = 12
)
}
@@ -13,21 +13,16 @@ enum class PermissionType {
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
},
/** Local network access permission (Android 17+) */
ACCESS_LOCAL_NETWORK {
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
override fun getPermission(): String = Manifest.permission.ACCESS_LOCAL_NETWORK
};
/** Return the actual Android permission string */
@@ -37,8 +32,8 @@ enum class PermissionType {
fun getLabel(): String {
return when (this) {
CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification"
ACCESS_LOCAL_NETWORK -> "Local Network"
}
}
}
@@ -0,0 +1,10 @@
package com.v2ray.ang.extension
/**
* Moves an item to another index while preserving the relative order of the remaining items.
*/
internal fun <T> MutableList<T>.moveItem(fromIndex: Int, toIndex: Int): Boolean {
if (fromIndex !in indices || toIndex !in indices || fromIndex == toIndex) return false
add(toIndex, removeAt(fromIndex))
return true
}
@@ -0,0 +1,53 @@
package com.v2ray.ang.extension
/**
* Removes all whitespace from the string.
*
* @return The string without whitespace.
*/
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
/**
* Returns null if the string is null or blank, otherwise returns the string itself.
*
* @return The string or null.
*/
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
/**
* Converts the string to a Long value, or returns 0 if the conversion fails.
*
* @return The Long value.
*/
fun String.toLongEx(): Long = toLongOrNull() ?: 0
/**
* Checks if the CharSequence is not null and not empty.
*
* @return True if the CharSequence is not null and not empty, false otherwise.
*/
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
fun String.concatUrl(vararg paths: String): String {
val builder = StringBuilder(this.trimEnd('/'))
paths.forEach { path ->
val trimmedPath = path.trim('/')
if (trimmedPath.isNotEmpty()) {
builder.append('/').append(trimmedPath)
}
}
return builder.toString()
}
/**
* Helper function to match text either by Regex or literal string.
*/
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
if (keyword.isNullOrEmpty()) {
return true
}
return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase)
}
@@ -0,0 +1,124 @@
package com.v2ray.ang.extension
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.v2ray.ang.ui.compose.AppSnackbarManager
import com.v2ray.ang.ui.compose.ToastType
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toast(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.NORMAL) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toast(message: CharSequence) {
dispatchMessage(message, ToastType.NORMAL) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastSuccess(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.SUCCESS) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastSuccess(message: CharSequence) {
dispatchMessage(message, ToastType.SUCCESS) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).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) {
val text = getString(message)
dispatchMessage(text, ToastType.ERROR) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
dispatchMessage(message, ToastType.ERROR) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastInfo(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.INFO) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastInfo(message: CharSequence) {
dispatchMessage(message, ToastType.INFO) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private inline fun runOnMain(crossinline block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
Handler(Looper.getMainLooper()).post { block() }
}
}
private inline fun dispatchMessage(
message: CharSequence,
type: ToastType,
long: Boolean = false,
crossinline fallback: () -> Unit
) {
val handledBySnackbar = AppSnackbarManager.show(
message = message,
type = type,
long = long
)
if (!handledBySnackbar) {
runOnMain { fallback() }
}
}
@@ -1,75 +1,13 @@
package com.v2ray.ang.extension
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import com.v2ray.ang.AngApplication
import es.dmoral.toasty.Toasty
import com.v2ray.ang.enums.EConfigType
import java.io.Serializable
import java.net.URI
import java.util.Locale
val Context.v2RayApplication: AngApplication?
get() = applicationContext as? AngApplication
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toast(message: Int) {
Toasty.normal(this, message).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toast(message: CharSequence) {
Toasty.normal(this, message).show()
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastSuccess(message: Int) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastSuccess(message: CharSequence) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastError(message: Int) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
}
const val THRESHOLD = 1000L
const val DIVISOR = 1024.0
@@ -99,56 +37,6 @@ fun Long.toTrafficString(): String {
val URI.idnHost: String
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
/**
* Removes all whitespace from the string.
*
* @return The string without whitespace.
*/
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
/**
* Returns null if the string is null or blank, otherwise returns the string itself.
*
* @return The string or null.
*/
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
/**
* Converts the string to a Long value, or returns 0 if the conversion fails.
*
* @return The Long value.
*/
fun String.toLongEx(): Long = toLongOrNull() ?: 0
/**
* Listens for package changes and executes a callback when a change occurs.
*
* @param onetime Whether to unregister the receiver after the first callback.
* @param callback The callback to execute when a package change occurs.
* @return The BroadcastReceiver that was registered.
*/
fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
callback()
if (onetime) context.unregisterReceiver(this)
}
}.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}, Context.RECEIVER_EXPORTED)
} else {
registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
})
}
}
/**
* Retrieves a serializable object from the Bundle.
*
@@ -172,32 +60,19 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
}
/**
* Checks if the CharSequence is not null and not empty.
* Checks if the config type is a group type (PolicyGroup or ProxyChain).
*
* @return True if the CharSequence is not null and not empty, false otherwise.
* @return True if the config type is PolicyGroup or ProxyChain, false otherwise.
*/
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
fun String.concatUrl(vararg paths: String): String {
val builder = StringBuilder(this.trimEnd('/'))
paths.forEach { path ->
val trimmedPath = path.trim('/')
if (trimmedPath.isNotEmpty()) {
builder.append('/').append(trimmedPath)
}
}
return builder.toString()
fun EConfigType.isGroupType(): Boolean {
return this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
/**
* Helper function to match text either by Regex or literal string.
* Checks if the config type is a complex type (Custom, PolicyGroup, or ProxyChain).
*
* @return True if the config type is Custom, PolicyGroup, or ProxyChain, false otherwise.
*/
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
if (keyword.isNullOrEmpty()) {
return true
}
return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase)
}
fun EConfigType.isComplexType(): Boolean {
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.JsonUtil
@@ -12,7 +12,7 @@ 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)
@@ -20,7 +20,7 @@ object CustomFmt : FmtBase() {
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
config.server = outbound?.getServerAddress()
config.serverPort = outbound?.getServerPort().toString()
config.serverPort = outbound?.getServerPort()?.toString()
return config
}
@@ -1,10 +1,9 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
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
@@ -51,15 +50,16 @@ open class FmtBase {
*
* @param config the ProfileItem object to populate
* @param queryParam the query parameters to use for populating the ProfileItem
* @param allowInsecure whether to allow insecure connections
*/
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>, allowInsecure: Boolean) {
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>) {
config.network = queryParam["type"] ?: NetworkType.TCP.type
config.headerType = queryParam["headerType"]
config.host = queryParam["host"]
config.path = queryParam["path"]
config.seed = queryParam["seed"]
config.kcpMtu = queryParam["mtu"]?.toIntOrNull()
config.kcpTti = queryParam["tti"]?.toIntOrNull()
config.quicSecurity = queryParam["quicSecurity"]
config.quicKey = queryParam["key"]
config.mode = queryParam["mode"]
@@ -67,6 +67,7 @@ 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) {
@@ -77,12 +78,13 @@ open class FmtBase {
config.insecure = when {
allowInsecureKeys.any { queryParam[it] == "1" } -> true
allowInsecureKeys.any { queryParam[it] == "0" } -> false
else -> allowInsecure
else -> false
}
config.sni = queryParam["sni"]
config.fingerPrint = queryParam["fp"]
config.alpn = queryParam["alpn"]
config.echConfigList = queryParam["ech"]
config.verifyPeerCertByName = queryParam["vcn"]
config.pinnedCA256 = queryParam["pcs"]
config.publicKey = queryParam["pbk"]
config.shortId = queryParam["sid"]
@@ -103,6 +105,7 @@ open class FmtBase {
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
config.verifyPeerCertByName?.nullIfBlank()?.let { dicQuery["vcn"] = it }
config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
@@ -110,6 +113,9 @@ open class FmtBase {
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
config.kcpMtu?.let { dicQuery["mtu"] = it.toString() }
config.kcpTti?.let { dicQuery["tti"] = it.toString() }
// Add two keys for compatibility: "insecure" and "allowInsecure"
if (config.security == AppConfig.TLS) {
val insecureFlag = if (config.insecure == true) "1" else "0"
@@ -166,21 +172,4 @@ open class FmtBase {
return dicQuery
}
fun getServerAddress(profileItem: ProfileItem): String {
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
return profileItem.server.orEmpty()
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
return domain
}
//Resolve and replace domain
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
if (resolvedIps.isNullOrEmpty()) {
return domain
}
return resolvedIps.first()
}
}
@@ -1,32 +0,0 @@
package com.v2ray.ang.fmt
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() {
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
}
@@ -1,16 +1,12 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -21,8 +17,7 @@ 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? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
fun parse(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -36,14 +31,11 @@ object Hysteria2Fmt : FmtBase() {
if (!uri.rawQuery.isNullOrEmpty()) {
val queryParam = getQueryParam(uri)
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
config.security = queryParam["security"] ?: AppConfig.TLS
config.obfsPassword = queryParam["obfs-password"]
config.portHopping = queryParam["mport"]
if (config.portHopping.isNotNullEmpty()) {
config.portHoppingInterval = queryParam["mportHopInt"]
}
config.pinnedCA256 = queryParam["pinSHA256"]
}
@@ -72,53 +64,10 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHopping.isNotNullEmpty()) {
dicQuery["mport"] = config.portHopping.orEmpty()
}
if (config.portHoppingInterval.isNotNullEmpty()) {
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
}
if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
}
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = 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)
}
if (profileItem.obfsPassword.isNotNullEmpty()) {
outboundBean.streamSettings?.finalmask = FinalMaskBean(
udp = listOf(
FinalMaskBean.MaskBean(
type = "salamander",
settings = FinalMaskBean.MaskBean.MaskSettingsBean(
password = profileItem.obfsPassword
)
)
)
)
}
return outboundBean
}
}
@@ -1,13 +1,11 @@
package com.v2ray.ang.fmt
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -85,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
config.remarks =
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
LogUtil.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
}
result = result.substring(0, indexSplit)
@@ -124,31 +122,4 @@ object ShadowsocksFmt : FmtBase() {
return toUri(config, Utils.encode(pw, true), null)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.method = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -1,11 +1,9 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -28,7 +26,11 @@ object SocksFmt : FmtBase() {
config.serverPort = uri.port.toString()
if (uri.userInfo?.isEmpty() == false) {
val result = Utils.decode(uri.userInfo).split(":", limit = 2)
val result = if (uri.userInfo.contains(":")) {
uri.userInfo.split(":", limit = 2)
} else {
Utils.decode(uri.userInfo).split(":", limit = 2)
}
if (result.count() == 2) {
config.username = result.first()
config.password = result.last()
@@ -53,27 +55,4 @@ object SocksFmt : FmtBase() {
return toUri(config, Utils.encode(pw, true), null)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
}
@@ -1,13 +1,10 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -18,8 +15,7 @@ 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? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
fun parse(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -31,11 +27,11 @@ object TrojanFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) {
config.network = NetworkType.TCP.type
config.security = AppConfig.TLS
config.insecure = allowInsecure
config.insecure = false
} else {
val queryParam = getQueryParam(uri)
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
config.security = queryParam["security"] ?: AppConfig.TLS
}
@@ -53,31 +49,4 @@ object TrojanFmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -0,0 +1,22 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.V2rayNShareItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
object V2rayNFmt : FmtBase() {
fun parse(str: String): ProfileItem? {
try {
val jsonBase64Payload = str.substringAfterLast('/')
val jsonPayload = Utils.decode(jsonBase64Payload)
val v2rayNShareItem = JsonUtil.fromJson(jsonPayload, V2rayNShareItem::class.java)
return v2rayNShareItem?.toProfileItem()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN format", e)
}
return null
}
}
@@ -1,12 +1,8 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -19,7 +15,6 @@ object VlessFmt : FmtBase() {
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VLESS)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -32,7 +27,7 @@ object VlessFmt : FmtBase() {
config.password = uri.userInfo
config.method = queryParam["encryption"] ?: "none"
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
return config
}
@@ -50,31 +45,4 @@ object VlessFmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].encryption = profileItem.method
vnext.users[0].flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -1,18 +1,15 @@
package com.v2ray.ang.fmt
import android.text.TextUtils
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -28,13 +25,12 @@ object VmessFmt : FmtBase() {
return parseVmessStd(str)
}
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.w(AppConfig.TAG, "Toast decoding failed")
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
return null
}
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
@@ -44,7 +40,7 @@ object VmessFmt : FmtBase() {
|| TextUtils.isEmpty(vmessQRCode.id)
|| TextUtils.isEmpty(vmessQRCode.net)
) {
Log.w(AppConfig.TAG, "Toast incorrect protocol")
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
return null
}
@@ -89,8 +85,11 @@ object VmessFmt : FmtBase() {
config.insecure = when (vmessQRCode.insecure) {
"1" -> true
"0" -> false
else -> allowInsecure
else -> false
}
config.verifyPeerCertByName = vmessQRCode.vcn
config.pinnedCA256 = vmessQRCode.pcs
return config
}
@@ -144,6 +143,8 @@ object VmessFmt : FmtBase() {
false -> "0"
else -> ""
}
vmessQRCode.vcn = config.verifyPeerCertByName.orEmpty()
vmessQRCode.pcs = config.pinnedCA256.orEmpty()
val json = JsonUtil.toJson(vmessQRCode)
return Utils.encode(json)
@@ -156,7 +157,6 @@ object VmessFmt : FmtBase() {
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parseVmessStd(str: String): ProfileItem? {
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VMESS)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -169,36 +169,10 @@ object VmessFmt : FmtBase() {
config.password = uri.userInfo
config.method = AppConfig.DEFAULT_SECURITY
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
return config
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].security = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -1,14 +1,11 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -31,7 +28,7 @@ object WireguardFmt : FmtBase() {
config.serverPort = uri.port.toString()
config.secretKey = uri.userInfo.orEmpty()
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.localAddress = queryParam["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
@@ -46,7 +43,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()
@@ -82,7 +79,7 @@ object WireguardFmt : FmtBase() {
config.secretKey = interfaceParams["privatekey"].orEmpty()
config.remarks = System.currentTimeMillis().toString()
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
@@ -100,29 +97,6 @@ object WireguardFmt : FmtBase() {
return config
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = 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?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
}
wireguard.mtu = profileItem.mtu
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
return outboundBean
}
/**
* Converts a ProfileItem object to a URI string.
@@ -3,14 +3,14 @@ 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.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt
@@ -18,17 +18,35 @@ import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt
import com.v2ray.ang.fmt.SocksFmt
import com.v2ray.ang.fmt.TrojanFmt
import com.v2ray.ang.fmt.V2rayNFmt
import com.v2ray.ang.fmt.VlessFmt
import com.v2ray.ang.fmt.VmessFmt
import com.v2ray.ang.fmt.WireguardFmt
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import java.net.URI
object AngConfigManager {
// Parser mapping for different config types (lazy initialized)
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
mapOf(
EConfigType.VMESS.protocolScheme to VmessFmt::parse,
EConfigType.SHADOWSOCKS.protocolScheme to ShadowsocksFmt::parse,
EConfigType.SOCKS.protocolScheme to SocksFmt::parse,
AppConfig.SOCKS4 to SocksFmt::parse,
AppConfig.SOCKS5 to SocksFmt::parse,
EConfigType.TROJAN.protocolScheme to TrojanFmt::parse,
EConfigType.VLESS.protocolScheme to VlessFmt::parse,
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
AppConfig.HY2 to Hysteria2Fmt::parse,
AppConfig.V2RAYNFMTS to V2rayNFmt::parse
)
}
/**
* Shares the configuration to the clipboard.
@@ -47,7 +65,7 @@ object AngConfigManager {
Utils.setClipboard(context, conf)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share config to clipboard", e)
return -1
}
return 0
@@ -76,7 +94,7 @@ object AngConfigManager {
}
return sb.lines().count() - 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
return -1
}
}
@@ -96,7 +114,7 @@ object AngConfigManager {
return QRCodeDecoder.createQRCode(conf)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config as QR code", e)
LogUtil.e(AppConfig.TAG, "Failed to share config as QR code", e)
return null
}
}
@@ -111,14 +129,14 @@ object AngConfigManager {
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
try {
if (guid == null) return -1
val result = V2rayConfigManager.getV2rayConfig(context, guid)
val result = CoreConfigManager.getV2rayConfig(context, guid)
if (result.status) {
Utils.setClipboard(context, result.content)
} else {
return -1
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
return -1
}
return 0
@@ -136,19 +154,16 @@ object AngConfigManager {
return config.configType.protocolScheme + when (config.configType) {
EConfigType.VMESS -> VmessFmt.toUri(config)
EConfigType.CUSTOM -> ""
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
EConfigType.SOCKS -> SocksFmt.toUri(config)
EConfigType.HTTP -> ""
EConfigType.VLESS -> VlessFmt.toUri(config)
EConfigType.TROJAN -> TrojanFmt.toUri(config)
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
EConfigType.POLICYGROUP -> ""
else -> {}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
LogUtil.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
return ""
}
}
@@ -167,7 +182,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 +218,7 @@ object AngConfigManager {
}
return count
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch subscription", e)
LogUtil.e(AppConfig.TAG, "Failed to parse batch subscription", e)
}
return 0
}
@@ -221,15 +236,8 @@ object AngConfigManager {
if (servers == null) {
return 0
}
// 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
}
// Find the currently selected server that belongs to the same subscription before replacement.
val removedSelected = getRemovedSelectedProfile(subid, append)
val subItem = MmkvManager.decodeSubscription(subid)
@@ -257,7 +265,7 @@ object AngConfigManager {
return configs.size
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
}
return 0
}
@@ -275,7 +283,6 @@ object AngConfigManager {
// Read serverList once
val serverList = MmkvManager.decodeServerList(subid)
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
configs.forEach { config ->
val key = Utils.getUuid()
@@ -284,10 +291,6 @@ object AngConfigManager {
if (!serverList.contains(key)) {
serverList.add(0, key)
if (needSetSelected) {
MmkvManager.setSelectServer(key)
needSetSelected = false
}
}
keyToProfile[key] = config
}
@@ -310,7 +313,18 @@ object AngConfigManager {
* @return Matched key or null
*/
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty() || target == null) return null
if (keyToProfile.isEmpty()) return null
if (target == null) return null
// Level 0: Full match (remarks + server + port + password)
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks) &&
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
}
// Level 1: Match by remarks
if (target.remarks.isNotBlank()) {
@@ -337,7 +351,20 @@ object AngConfigManager {
isSameText(saved.server, target.server)
}?.key?.let { return it }
return null
// If old selected node cannot be matched, fall back to the first imported config.
return keyToProfile.keys.firstOrNull()
}
/**
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
*/
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
if (subid.isBlank() || append) return null
return MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
}
/**
@@ -357,9 +384,10 @@ object AngConfigManager {
*
* @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
}
@@ -372,19 +400,29 @@ object AngConfigManager {
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) {
val removedSelected = getRemovedSelectedProfile(subid, append)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
val keyToProfile = mutableMapOf<String, ProfileItem>()
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
keyToProfile[key] = config
count += 1
}
if (count > 0) {
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
return count
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
}
try {
@@ -392,22 +430,28 @@ object AngConfigManager {
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) {
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
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) {
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
return 0
} else {
@@ -434,22 +478,8 @@ object AngConfigManager {
return null
}
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
VmessFmt.parse(str)
} else if (str.startsWith(EConfigType.SHADOWSOCKS.protocolScheme)) {
ShadowsocksFmt.parse(str)
} else if (str.startsWith(EConfigType.SOCKS.protocolScheme)) {
SocksFmt.parse(str)
} else if (str.startsWith(EConfigType.TROJAN.protocolScheme)) {
TrojanFmt.parse(str)
} else if (str.startsWith(EConfigType.VLESS.protocolScheme)) {
VlessFmt.parse(str)
} else if (str.startsWith(EConfigType.WIREGUARD.protocolScheme)) {
WireguardFmt.parse(str)
} else if (str.startsWith(EConfigType.HYSTERIA2.protocolScheme) || str.startsWith(HY2)) {
Hysteria2Fmt.parse(str)
} else {
null
val config = configFmtParsers.firstNotNullOfOrNull { (scheme, parser) ->
if (str.startsWith(scheme)) parser(str) else null
}
if (config == null) {
@@ -466,9 +496,15 @@ object AngConfigManager {
config.subscriptionId = subid
config.description = generateDescription(config)
if (str.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)
&& config.policyGroupSubscriptionId == "self"
) {
config.policyGroupSubscriptionId = subid
}
return config
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse config", e)
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
return null
}
}
@@ -485,7 +521,7 @@ object AngConfigManager {
acc + updateConfigViaSub(subscription)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
LogUtil.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
SubscriptionUpdateResult()
}
}
@@ -520,21 +556,40 @@ object AngConfigManager {
return SubscriptionUpdateResult(failureCount = 1)
}
}
Log.i(AppConfig.TAG, url)
LogUtil.i(AppConfig.TAG, url)
val userAgent = it.subscription.userAgent
val requestHeaders = it.subscription.requestHeaders
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent,
requestHeaders = requestHeaders,
timeout = 15000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
)
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent,
requestHeaders = requestHeaders
)
)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
}
}
@@ -546,7 +601,7 @@ object AngConfigManager {
if (count > 0) {
it.subscription.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(it.guid, it.subscription)
Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
return SubscriptionUpdateResult(
configCount = count,
successCount = 1
@@ -556,11 +611,46 @@ object AngConfigManager {
return SubscriptionUpdateResult(failureCount = 1)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
LogUtil.e(AppConfig.TAG, "Failed to update config via subscription", e)
return SubscriptionUpdateResult(failureCount = 1)
}
}
/**
* Removes invalid server configurations for a subscription.
*
* @param subId The subscription ID.
*/
fun removeInvalidServer(subId: String) {
val serverList = MmkvManager.decodeServerList(subId)
val invalidServers = serverList.filter {
val aff = MmkvManager.decodeServerAffiliationInfo(it)
aff != null && aff.testDelayMillis < 0L
}
MmkvManager.removeServers(invalidServers, subId)
}
/**
* Sorts servers by test results for a subscription.
*
* @param subId The subscription ID.
*/
fun sortByTestResultsForSub(subId: String) {
val serverList = MmkvManager.decodeServerList(subId)
if (serverList.isEmpty()) return
val sorted = serverList
.map { guid ->
val delay =
MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
guid to if (delay <= 0L) Long.MAX_VALUE else delay
}
.sortedBy { it.second }
.map { it.first }
.toMutableList()
MmkvManager.encodeServerList(sorted, subId)
}
/**
* Parses the configuration via a subscription.
*
@@ -575,7 +665,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid)
count = parseCustomConfigServer(server, subid, append)
}
return count
}
@@ -0,0 +1,79 @@
package com.v2ray.ang.handler
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.CertSha256Request
import com.v2ray.ang.dto.CertSha256Result
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import libv2ray.Libv2ray
object CertificateFingerprintManager {
private const val TIMEOUT_MS = 5000L
fun fetchForManualFill(profile: ProfileItem): String? {
val request = buildRequest(profile) ?: return null
val result = if (profile.configType == EConfigType.HYSTERIA2) {
fetch("quic", request) { Libv2ray.fetchQuicCertSha256(it) }
} else {
fetch("tls", request) { Libv2ray.fetchTlsCertSha256(it) }
}
return result
?.takeIf { it.error.isBlank() }
?.sha256
?.takeIf { it.isNotBlank() }
}
private fun buildRequest(profile: ProfileItem): CertSha256Request? {
if (!isFetchable(profile)) return null
val server = profile.server?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val port = profile.serverPort?.toIntOrNull()?.takeIf { it > 0 } ?: AppConfig.DEFAULT_PORT
return CertSha256Request(
address = resolveDialAddress(server),
port = port,
serverName = inferServerName(profile),
timeoutMs = TIMEOUT_MS,
)
}
private fun isFetchable(profile: ProfileItem): Boolean {
return profile.configType == EConfigType.HYSTERIA2 || profile.security == AppConfig.TLS
}
private fun fetch(
type: String,
request: CertSha256Request,
fetcher: (String) -> String,
): CertSha256Result? {
return try {
JsonUtil.fromJsonSafe(fetcher(JsonUtil.toJson(request)), CertSha256Result::class.java)
} catch (e: UnsatisfiedLinkError) {
LogUtil.e(AppConfig.TAG, "Fetch $type cert SHA-256 API missing in libv2ray", e)
null
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Fetch $type cert SHA-256 failed", e)
null
}
}
private fun resolveDialAddress(server: String): String {
if (Utils.isPureIpAddress(server) || !Utils.isDomainName(server)) return server
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6, false)
return HttpUtil.resolveHostToIP(server, preferIpv6)
?.firstOrNull()
?.takeIf { it.isNotBlank() }
?: server
}
private fun inferServerName(profile: ProfileItem): String? {
val sni = profile.sni?.takeIf { it.isNotBlank() }
return sni?.takeUnless { Utils.isPureIpAddress(it) }
}
}
@@ -1,19 +1,28 @@
package com.v2ray.ang.handler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
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.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
object MmkvManager {
@@ -102,7 +111,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
@@ -144,7 +153,7 @@ object MmkvManager {
if (json.isNullOrBlank()) {
return null
}
return JsonUtil.fromJson(json, ProfileItem::class.java)
return JsonUtil.fromJsonSafe(json, ProfileItem::class.java)
}
@@ -233,6 +242,31 @@ object MmkvManager {
encodeServerList(serverList, subId)
}
/**
* Removes multiple server configurations from a subscription.
*
* @param guids The list of server GUIDs.
* @param subscriptionId The subscription ID.
*/
fun removeServers(guids: List<String>, subscriptionId: String) {
if (guids.isEmpty()) return
val subId = getSubscriptionId(subscriptionId)
val serverList = decodeServerList(subId)
if (serverList.removeAll(guids)) {
encodeServerList(serverList, subId)
}
val selectedServer = getSelectServer()
guids.forEach { guid ->
if (selectedServer == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
profileFullStorage.remove(guid)
serverAffStorage.remove(guid)
serverRawStorage.remove(guid)
}
}
/**
* Decodes the server affiliation information.
*
@@ -247,7 +281,7 @@ object MmkvManager {
if (json.isNullOrBlank()) {
return null
}
return JsonUtil.fromJson(json, ServerAffiliationInfo::class.java)
return JsonUtil.fromJsonSafe(json, ServerAffiliationInfo::class.java)
}
/**
@@ -286,9 +320,13 @@ object MmkvManager {
*/
fun removeAllServer(): Int {
val count = profileFullStorage.allKeys()?.count() ?: 0
mainStorage.clearAll()
profileFullStorage.clearAll()
serverAffStorage.clearAll()
serverRawStorage.clearAll()
decodeSubscriptions().forEach { sub ->
encodeServerList(mutableListOf(), sub.guid)
}
return count
}
@@ -344,7 +382,7 @@ object MmkvManager {
//region Subscriptions
private fun getSubscriptionId(subscriptionId: String?):String {
private fun getSubscriptionId(subscriptionId: String?): String {
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
}
@@ -374,7 +412,7 @@ object MmkvManager {
decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
val item = JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java) ?: SubscriptionItem()
subscriptions.add(SubscriptionCache(key, item))
}
}
@@ -420,7 +458,7 @@ object MmkvManager {
*/
fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
val json = subStorage.decodeString(subscriptionId) ?: return null
return JsonUtil.fromJson(json, SubscriptionItem::class.java)
return JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java)
}
/**
@@ -442,7 +480,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList()?: mutableListOf()
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
@@ -460,7 +498,7 @@ object MmkvManager {
assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
val item = JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java) ?: AssetUrlItem()
assetUrlItems.add(AssetUrlCache(key, item))
}
}
@@ -495,7 +533,7 @@ object MmkvManager {
*/
fun decodeAsset(assetid: String): AssetUrlItem? {
val json = assetStorage.decodeString(assetid) ?: return null
return JsonUtil.fromJson(json, AssetUrlItem::class.java)
return JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java)
}
//endregion
@@ -510,7 +548,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()?: mutableListOf()
return JsonUtil.fromJsonSafe(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
}
/**
@@ -679,6 +717,7 @@ object MmkvManager {
return settingsStorage.decodeStringSet(key)
}
/**
* Encodes the start on boot setting.
*
@@ -713,7 +752,59 @@ object MmkvManager {
*/
fun decodeWebDavConfig(): WebDavConfig? {
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
return JsonUtil.fromJson(json, WebDavConfig::class.java)
return JsonUtil.fromJsonSafe(json, WebDavConfig::class.java)
}
//endregion
//region Compose helpers for Settings
/**
* MMKV-backed String state, auto-persists and notifies on change.
*/
@Composable
fun rememberMmkvString(
key: String,
default: String = ""
): MutableState<String> {
val state = remember(key) {
mutableStateOf(decodeSettingsString(key, default) ?: default)
}
LaunchedEffect(key) {
snapshotFlow { state.value }
.drop(1)
.distinctUntilChanged()
.collectLatest { value ->
encodeSettings(key, value)
SettingsChangeManager.notifySettingChanged(key)
}
}
return state
}
/**
* MMKV-backed Boolean state, auto-persists and notifies on change.
*/
@Composable
fun rememberMmkvBool(
key: String,
default: Boolean = false
): MutableState<Boolean> {
val state = remember(key) {
mutableStateOf(decodeSettingsBool(key, default))
}
LaunchedEffect(key) {
snapshotFlow { state.value }
.drop(1)
.distinctUntilChanged()
.collectLatest { value ->
encodeSettings(key, value)
SettingsChangeManager.notifySettingChanged(key)
}
}
return state
}
//endregion
@@ -9,14 +9,15 @@ import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.ui.MainActivity
import com.v2ray.ang.ui.main.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -42,53 +43,15 @@ object NotificationManager {
* Starts the speed notification.
* @param currentConfig The current profile configuration.
*/
fun startSpeedNotification(currentConfig: ProfileItem?) {
fun startSpeedNotification() {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags()
outboundTags?.remove(AppConfig.TAG_DIRECT)
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
val queryTime = System.currentTimeMillis()
val sinceLastQueryIn = (queryTime - lastQueryTime)
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
Log.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 {
val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
if (up + down > 0) {
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
proxyTotal += up + down
}
}
val directUplink = V2RayServiceManager.queryStats(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, AppConfig.TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
}
lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime
lastZeroSpeed = updateSpeedNotificationOnce(lastZeroSpeed)
delay(QUERY_INTERVAL_MS)
}
}
@@ -130,8 +93,8 @@ object NotificationManager {
mBuilder = NotificationCompat.Builder(service, channelId)
.setSmallIcon(R.drawable.ic_stat_name)
.setContentTitle(currentConfig?.remarks)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setContentTitle(currentConfig?.remarks ?: service.getString(R.string.app_name))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setShowWhen(false)
.setOnlyAlertOnce(true)
@@ -142,7 +105,7 @@ object NotificationManager {
stopV2RayPendingIntent
)
.addAction(
R.drawable.ic_delete_24dp,
R.drawable.ic_restore_24dp,
service.getString(R.string.title_service_restart),
restartV2RayPendingIntent
)
@@ -152,6 +115,17 @@ object NotificationManager {
service.startForeground(NOTIFICATION_ID, mBuilder?.build())
}
/**
* Fulfills or refreshes the foreground-service contract before a start command can
* return early. A duplicate startForegroundService call still requires the service
* to enter foreground state promptly, even when the core is already running.
*/
fun ensureForeground() {
val service = getService() ?: return
val notification = mBuilder?.build()
if (notification == null) showNotification(null) else service.startForeground(NOTIFICATION_ID, notification)
}
/**
* Cancels the notification.
*/
@@ -167,13 +141,12 @@ object NotificationManager {
/**
* Stops the speed notification.
* @param currentConfig The current profile configuration.
*/
fun stopSpeedNotification(currentConfig: ProfileItem?) {
fun stopSpeedNotification() {
speedNotificationJob?.let {
it.cancel()
speedNotificationJob = null
updateNotification(currentConfig?.remarks, 0, 0)
updateNotification("", 0, 0)
}
}
@@ -185,12 +158,9 @@ object NotificationManager {
private fun createNotificationChannel(): String {
val channelId = AppConfig.RAY_NG_CHANNEL_ID
val channelName = AppConfig.RAY_NG_CHANNEL_NAME
val chan = NotificationChannel(
channelId,
channelName, NotificationManager.IMPORTANCE_HIGH
)
// Foreground-service notifications must remain visible; LOW is silent but valid.
val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
chan.lightColor = Color.DKGRAY
chan.importance = NotificationManager.IMPORTANCE_NONE
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
getNotificationManager()?.createNotificationChannel(chan)
return channelId
@@ -246,11 +216,74 @@ object NotificationManager {
text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
}
/**
* Updates the speed notification once.
* Queries traffic stats, separates proxy and direct, and updates the notification.
* @param lastZeroSpeed The previous zero speed state.
* @return The current zero speed state.
*/
private fun updateSpeedNotificationOnce(lastZeroSpeed: Boolean): Boolean {
val queryTime = System.currentTimeMillis()
val sinceLastQueryIn = (queryTime - lastQueryTime)
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
return lastZeroSpeed
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyUplink = 0L
var proxyDownlink = 0L
var directUplink = 0L
var directDownlink = 0L
CoreServiceManager.queryAllOutboundTrafficStats().forEach { stat ->
when {
stat.tag == AppConfig.TAG_DIRECT -> {
when (stat.direction) {
AppConfig.UPLINK -> directUplink += stat.value
AppConfig.DOWNLINK -> directDownlink += stat.value
}
}
stat.tag.startsWith(AppConfig.TAG_PROXY) -> {
when (stat.direction) {
AppConfig.UPLINK -> proxyUplink += stat.value
AppConfig.DOWNLINK -> proxyDownlink += stat.value
}
}
}
}
val proxyTotal = proxyUplink + proxyDownlink
val directTotal = directUplink + directDownlink
val zeroSpeed = proxyTotal + directTotal == 0L
if (!zeroSpeed || !lastZeroSpeed) {
val text = StringBuilder()
appendSpeedString(
text, AppConfig.TAG_PROXY,
proxyUplink / sinceLastQueryInSeconds,
proxyDownlink / sinceLastQueryInSeconds
)
appendSpeedString(
text, AppConfig.TAG_DIRECT,
directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directTotal)
}
lastQueryTime = queryTime
return zeroSpeed
}
/**
* Gets the service instance.
* @return The service instance.
*/
private fun getService(): Service? {
return V2RayServiceManager.serviceControl?.get()?.getService()
return CoreServiceManager.serviceControl?.get()?.getService()
}
}
}
@@ -1,32 +1,57 @@
package com.v2ray.ang.handler
import kotlinx.coroutines.flow.MutableStateFlow
import com.v2ray.ang.AppConfig
import java.util.concurrent.atomic.AtomicBoolean
/**
* Manages global flags for actions triggered by setting changes.
* Uses AtomicBoolean for thread-safe consume operations.
*/
object SettingsChangeManager {
private val _restartService = MutableStateFlow(false)
private val _setupGroupTab = MutableStateFlow(false)
// Mark restartService as requiring a restart
private val restartService = AtomicBoolean(false)
private val setupGroupTab = AtomicBoolean(false)
// Keys that affect only UI behavior and do not require core service restart.
private val uiOnlyKeys = setOf(
AppConfig.PREF_CONFIRM_REMOVE,
AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
AppConfig.PREF_GROUP_ALL_DISPLAY,
AppConfig.PREF_LANGUAGE,
AppConfig.PREF_UI_MODE_NIGHT,
AppConfig.PREF_IS_BOOTED,
)
/**
* Called when a setting value changes.
* Triggers service restart if the key is not UI-only, and always refreshes UI tabs.
*/
fun notifySettingChanged(key: String) {
if (key !in uiOnlyKeys) {
makeRestartService()
}
makeSetupGroupTab()
}
fun makeRestartService() {
_restartService.value = true
restartService.set(true)
}
// Read and clear the restartService flag
fun consumeRestartService(): Boolean {
val v = _restartService.value
_restartService.value = false
return v
}
/**
* Atomically consumes the restart flag.
* @return true if a restart was requested, false otherwise.
*/
fun consumeRestartService(): Boolean =
restartService.compareAndSet(true, false)
// Mark reinitGroupTab as requiring tab reinitialization
fun makeSetupGroupTab() {
_setupGroupTab.value = true
setupGroupTab.set(true)
}
// Read and clear the reinitGroupTab flag
fun consumeSetupGroupTab(): Boolean {
val v = _setupGroupTab.value
_setupGroupTab.value = false
return v
}
/**
* Atomically consumes the setup-group-tab flag.
* @return true if UI refresh was requested, false otherwise.
*/
fun consumeSetupGroupTab(): Boolean =
setupGroupTab.compareAndSet(true, false)
}
@@ -2,9 +2,8 @@ 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
@@ -12,14 +11,15 @@ import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.extension.moveItem
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
@@ -27,14 +27,18 @@ 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()
@@ -68,7 +72,7 @@ object SettingsManager {
return null
}
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java)?.toMutableList()
return JsonUtil.fromJsonSafe(assets, Array<RulesetItem>::class.java)?.toMutableList()
}
/**
@@ -92,7 +96,7 @@ object SettingsManager {
}
try {
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java)?.toMutableList()
val rulesetList = JsonUtil.fromJsonSafe(content, Array<RulesetItem>::class.java)?.toMutableList()
if (rulesetList.isNullOrEmpty()) {
return false
}
@@ -100,7 +104,7 @@ object SettingsManager {
resetRoutingRulesetsCommon(rulesetList)
return true
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
LogUtil.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
return false
}
}
@@ -186,7 +190,7 @@ object SettingsManager {
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 v2rayConfig = JsonUtil.fromJsonSafe(raw, V2rayConfig::class.java)
val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any {
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
}
@@ -200,32 +204,6 @@ object SettingsManager {
return exist == true
}
/**
* Swap routing rulesets.
* @param fromPosition The position to swap from.
* @param toPosition The position to swap to.
*/
fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) {
val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) return
Collections.swap(rulesetList, fromPosition, toPosition)
MmkvManager.encodeRoutingRulesets(rulesetList)
}
/**
* Swap subscriptions.
* @param fromPosition The position to swap from.
* @param toPosition The position to swap to.
*/
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
val subsList = MmkvManager.decodeSubsList()
if (subsList.isEmpty()) return
Collections.swap(subsList, fromPosition, toPosition)
MmkvManager.encodeSubsList(subsList)
}
/**
* Get server via remarks.
* @param remarks The remarks of the server.
@@ -241,18 +219,27 @@ object SettingsManager {
.firstOrNull { it.remarks == remarks }
}
/**
* Collects non-empty profile remarks while excluding specific config types.
*/
fun getProfileRemarks(excludeConfigTypes: Set<EConfigType> = setOf(EConfigType.CUSTOM)): List<String> {
return decodeAllServerList()
.asSequence()
.mapNotNull { guid -> decodeServerConfig(guid) }
.filter { profile -> profile.configType !in excludeConfigTypes }
.map { it.remarks.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
}
/**
* Removes the subscription.
* If there are no remaining subscriptions,
* it creates a new default subscription to ensure that ungroup
**/
fun removeSubscriptionWithDefault(subid: String) {
// val subsList = decodeSubsList()
// if (subsList.size == 1 && subsList.first() == DEFAULT_SUBSCRIPTION_ID) {
// Log.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
// return
// }
SubscriptionUpdater.cancelOne(subId = subid)
// Remove the subscription
removeSubscription(subid)
@@ -273,7 +260,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() }
}
/**
@@ -284,6 +294,14 @@ object SettingsManager {
return getSocksPort() + if (Utils.isXray()) 0 else 1
}
private fun IsDynamicSocksPort(): Boolean {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
}
private fun generateRandomSocksPort(): Int {
return Random.nextInt(10000, 65535)
}
/**
* Initialize assets.
* @param context The application context.
@@ -304,10 +322,10 @@ object SettingsManager {
input.copyTo(output)
}
}
Log.i(AppConfig.TAG, "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)
}
}
@@ -362,6 +380,15 @@ object SettingsManager {
}
}
/**
* Get real ping concurrency.
* @return The number of concurrent real-ping tests (clamped to 1..64).
*/
fun getRealPingConcurrency(): Int {
val value = MmkvManager.decodeSettingsString(AppConfig.PREF_REAL_PING_CONCURRENCY)?.toIntOrNull() ?: 16
return value.coerceIn(1, 128)
}
/**
* Get the locale.
* @return The locale.
@@ -385,17 +412,6 @@ object SettingsManager {
}
}
/**
* Set night mode.
*/
fun setNightMode() {
when (MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0")) {
"0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
"1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
}
/**
* Retrieves the currently selected VPN interface address configuration.
* This method reads the user's preference for VPN interface addressing and returns
@@ -433,15 +449,43 @@ object SettingsManager {
return mode == null || mode == VPN
}
/**
* Check if a root (system-wide) run mode is selected.
*/
fun isRootMode(): Boolean {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
}
/**
* Check if process routing can be used.
*/
fun canUseProcessRouting(): Boolean {
// Android 10+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return false
}
// Must xray tun
if (isUsingHevTun()) {
return false
}
// Must have route only enabled
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
return false
}
return true
}
/**
* Ensure default settings are present in MMKV.
*/
private fun ensureDefaultSettings() {
// Write defaults in the exact order requested by the user
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
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)
@@ -452,6 +496,12 @@ object SettingsManager {
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_PING_INTERVAL, AppConfig.OBSERVATORY_LEAST_PING_INTERVAL)
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_INTERVAL, AppConfig.OBSERVATORY_LEAST_LOAD_INTERVAL)
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_METHOD, AppConfig.OBSERVATORY_LEAST_LOAD_METHOD)
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_SAMPLING, AppConfig.OBSERVATORY_LEAST_LOAD_SAMPLING)
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT, AppConfig.OBSERVATORY_LEAST_LOAD_TIMEOUT)
}
private fun ensureDefaultValue(key: String, default: String) {
@@ -510,7 +560,7 @@ object SettingsManager {
return
}
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
val guids = JsonUtil.fromJsonSafe(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true)
return
}
@@ -547,10 +597,10 @@ object SettingsManager {
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
// Move top
// Move to the top
val subsList = decodeSubsList()
if (subsList.count() > 1) {
swapSubscriptions(0, subsList.count() - 1)
if (subsList.moveItem(subsList.lastIndex, 0)) {
MmkvManager.encodeSubsList(subsList)
}
}
}
@@ -1,15 +1,11 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import com.v2ray.ang.util.LogUtil
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
@@ -17,29 +13,6 @@ import java.net.UnknownHostException
object SpeedtestManager {
private val tcpTestingSockets = ArrayList<Socket?>()
/**
* Measures the TCP connection time to a given URL and port.
*
* @param url The URL to connect to.
* @param port The port to connect to.
* @return The connection time in milliseconds, or -1 if the connection failed.
*/
suspend fun tcping(url: String, port: Int): Long {
var time = -1L
for (k in 0 until 2) {
val one = socketConnectTime(url, port)
if (!currentCoroutineContext().isActive) {
break
}
if (one != -1L && (time == -1L || one < time)) {
time = one
}
}
return time
}
/**
* Measures the time taken to establish a TCP connection to a given URL and port.
*
@@ -47,86 +20,52 @@ object SpeedtestManager {
* @param port The port to connect to.
* @return The connection time in milliseconds, or -1 if the connection failed.
*/
fun socketConnectTime(url: String, port: Int): Long {
fun socketConnectTime(url: String, port: Int, timeoutMs: Int = 1500): Long {
var socket: Socket? = null
val start = System.currentTimeMillis()
try {
val socket = Socket()
synchronized(this) {
tcpTestingSockets.add(socket)
}
val start = System.currentTimeMillis()
socket.connect(InetSocketAddress(url, port), 3000)
val time = System.currentTimeMillis() - start
synchronized(this) {
tcpTestingSockets.remove(socket)
}
socket.close()
return time
socket = Socket()
socket.connect(InetSocketAddress(url, port), timeoutMs)
return System.currentTimeMillis() - start
} catch (e: UnknownHostException) {
Log.e(AppConfig.TAG, "Unknown host: $url", e)
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
} catch (e: IOException) {
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: ${e.message}")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
} finally {
socket?.let { s ->
try {
if (!s.isClosed) {
s.close()
}
} catch (closeEx: IOException) {
}
}
}
return -1
}
/**
* Closes all TCP sockets that are currently being tested.
*/
fun closeAllTcpSockets() {
synchronized(this) {
tcpTestingSockets.forEach {
it?.close()
}
tcpTestingSockets.clear()
}
}
/**
* 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
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
result = when (code) {
204 -> context.getString(R.string.connection_test_available, elapsed)
200 if conn.contentLengthLong == 0L -> 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.e(AppConfig.TAG, "Connection test IOException", e)
result = context.getString(R.string.connection_test_error, e.message)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Connection test Exception", e)
result = context.getString(R.string.connection_test_error, e.message)
} finally {
conn.disconnect()
}
return Pair(elapsed, result)
}
fun getRemoteIPInfo(): String? {
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
val content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: return null
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
if (httpPort == 0) return null
val content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
) ?: return null
val ipInfo = JsonUtil.fromJsonSafe(content, IPAPIInfo::class.java) ?: return null
val ip = listOf(
ipInfo.ip,
@@ -1,63 +1,189 @@
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.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.multiprocess.RemoteWorkManager
import androidx.work.workDataOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import java.util.concurrent.TimeUnit
object SubscriptionUpdater {
// -------------------------------------------------------------------------
// Public API — the only methods external callers should ever use
// -------------------------------------------------------------------------
/**
* Sync all subscription tasks with current settings.
*
* Startup/boot callers should use the default mode so existing periodic work is kept.
* Use forceReschedule=true only when the next run time needs to be recalculated from
* the latest persisted subscription state (for example after a manual refresh).
* Call from: MainActivity.onCreate(), BootReceiver.onReceive().
*/
fun sync(
context: Context = AngApplication.application,
forceReschedule: Boolean = false
) {
val existingWorkPolicy =
if (forceReschedule) {
ExistingPeriodicWorkPolicy.REPLACE
} else {
ExistingPeriodicWorkPolicy.KEEP
}
MmkvManager.decodeSubscriptions()
.filter { it.subscription.autoUpdate && it.subscription.url.isNotEmpty() }
.forEach { sub ->
scheduleOne(
context = context,
subId = sub.guid,
existingWorkPolicy = existingWorkPolicy
)
}
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: sync complete forceReschedule=$forceReschedule"
)
}
/**
* Sync a single subscription's task.
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
*/
fun syncOne(context: Context = AngApplication.application, subId: String) {
scheduleOne(
context = context,
subId = subId,
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
)
}
/**
* Cancel the auto-update task for a single subscription.
* Call from: when a subscription is deleted.
*/
fun cancelOne(context: Context = AngApplication.application, subId: String) {
RemoteWorkManager.getInstance(context)
.cancelUniqueWork(taskName(subId))
}
/**
* Update the last updated timestamp and reschedule the task.
* This is used to reset the periodic timer and prevent rapid rescheduling loops.
*/
fun updateLastUpdatedAndReschedule(context: Context = AngApplication.application, subId: String) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
subItem.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(subId, subItem)
syncOne(context, subId)
}
// -------------------------------------------------------------------------
// Internal scheduling logic
// -------------------------------------------------------------------------
private fun taskName(subId: String) = "${AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME}_$subId"
private fun scheduleOne(
context: Context,
subId: String,
existingWorkPolicy: ExistingPeriodicWorkPolicy
) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
val rw = RemoteWorkManager.getInstance(context)
if (!subItem.autoUpdate) {
cancelOne(context, subId)
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for ${subItem.remarks}")
return
}
if (subItem.url.isEmpty()) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: url isEmpty for ${subItem.remarks}, skip")
return
}
val intervalMinutes = maxOf(
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
subItem.updateInterval
)
// Base initial delay on the last successful update time persisted in subscription.
val lastUpdated = subItem.lastUpdated
val intervalMillis = intervalMinutes * 60 * 1000L
val now = System.currentTimeMillis()
var initialDelayMillis = if (lastUpdated <= 0L) {
0L
} else {
maxOf(0L, lastUpdated + intervalMillis - now)
}
// Add a small floor to initial delay to prevent rapid rescheduling loops.
if (existingWorkPolicy == ExistingPeriodicWorkPolicy.REPLACE && initialDelayMillis < 5000L) {
initialDelayMillis = 5000L
}
val request = PeriodicWorkRequestBuilder<UpdateTask>(intervalMinutes, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(workDataOf(KEY_SUB_ID to subId))
.setInitialDelay(initialDelayMillis, TimeUnit.MILLISECONDS)
.addTag(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
.build()
rw.enqueueUniquePeriodicWork(
taskName(subId),
existingWorkPolicy,
request
)
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: scheduled [${subItem.remarks}] interval=${intervalMinutes}min " +
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
)
}
// -------------------------------------------------------------------------
// Worker
// -------------------------------------------------------------------------
private const val KEY_SUB_ID = "subId"
class UpdateTask(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
private val notificationManager = NotificationManagerCompat.from(applicationContext)
private val notification =
NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
.setWhen(0)
.setTicker("Update")
.setContentTitle(context.getString(R.string.title_pref_auto_update_subscription))
.setSmallIcon(R.drawable.ic_stat_name)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
/**
* Performs the subscription update work.
* @return The result of the work.
*/
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
Log.i(AppConfig.TAG, "subscription automatic update starting")
val subId = inputData.getString(KEY_SUB_ID)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update starting via Service: $subId")
val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
for (sub in subs) {
val subItem = sub.subscription
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
val channel =
NotificationChannel(
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
NotificationManager.IMPORTANCE_MIN
)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(3, notification.build())
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
AngConfigManager.updateConfigViaSub(sub)
notification.setContentText("Updating ${subItem.remarks}")
if (subId.isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
return Result.success()
}
notificationManager.cancel(3)
updateLastUpdatedAndReschedule(applicationContext, subId)
MessageHelper.sendMsg2SubscriptionService(
applicationContext,
SubscriptionUpdateMessage(AppConfig.MSG_SUB_UPDATE_START, true, listOf(subId))
)
return Result.success()
}
}
}
}
@@ -1,19 +1,17 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.Build
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.dto.GitHubRelease
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
object UpdateCheckerManager {
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
@@ -23,26 +21,42 @@ object UpdateCheckerManager {
AppConfig.APP_API_URL.concatUrl("latest")
}
var response = HttpUtil.getUrlContent(url, 5000)
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var response = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000
)
)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort)
response = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
)
?: throw IllegalStateException("Failed to get response")
}
val latestRelease = if (includePreRelease) {
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
JsonUtil.fromJsonSafe(response, Array<GitHubRelease>::class.java)
?.firstOrNull()
?: throw IllegalStateException("No pre-release found")
} else {
JsonUtil.fromJson(response, GitHubRelease::class.java)
JsonUtil.fromJsonSafe(response, GitHubRelease::class.java)
}
if (latestRelease == null) {
return@withContext CheckUpdateResult(hasUpdate = false)
}
val latestVersion = latestRelease.tagName.removePrefix("v")
Log.i(
LogUtil.i(
AppConfig.TAG,
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
)
@@ -61,39 +75,6 @@ object UpdateCheckerManager {
}
}
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try {
val httpPort = SettingsManager.getHttpPort()
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
?: throw IllegalStateException("Failed to create connection")
try {
val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Log.i(AppConfig.TAG, "APK download completed")
return@withContext apkFile
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
return@withContext null
} finally {
try {
connection.disconnect()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
return@withContext null
}
}
private fun compareVersions(version1: String, version2: String): Int {
val v1 = version1.split(".")
val v2 = version2.split(".")
@@ -1,418 +0,0 @@
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.os.ParcelFileDescriptor
import android.util.Log
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.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 java.lang.ref.SoftReference
object V2RayServiceManager {
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null
var serviceControl: SoftReference<ServiceControl>? = null
set(value) {
field = value
V2RayNativeManager.initCoreEnv(value?.get()?.getService())
}
/**
* 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) {
Log.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) {
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
Log.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())
) {
Log.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
return
}
// 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) {
Log.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, V2RayVpnService::class.java)
} else {
Log.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
Log.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) {
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false
}
val service = getService()
if (service == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: Service is null")
return false
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return false
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return false
}
Log.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
if (!result.status) {
Log.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) {
Log.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) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
return false
}
if (coreController.isRunning == false) {
Log.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)
Log.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
} catch (e: Exception) {
Log.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) {
Log.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) {
Log.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) {
Log.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) {
Log.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) {
Log.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
}
}
/**
* 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 -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
serviceControl.stopService()
}
AppConfig.MSG_STATE_RESTART -> {
Log.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 -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification(currentConfig)
}
Intent.ACTION_SCREEN_ON -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification(currentConfig)
}
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,8 @@
package com.v2ray.ang.handler
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Credentials
@@ -67,14 +67,14 @@ object WebDavManager {
cl.newCall(req).execute().use { resp ->
val success = resp.isSuccessful
if (success) {
Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
} else {
Log.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
LogUtil.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
}
return@withContext success
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
return@withContext false
}
}
@@ -93,7 +93,7 @@ object WebDavManager {
val req = applyAuth(Request.Builder().url(remote).get()).build()
cl.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
LogUtil.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
return@withContext false
}
@@ -104,11 +104,11 @@ object WebDavManager {
}
}
Log.i(AppConfig.TAG, "WebDAV download success: $remote")
LogUtil.i(AppConfig.TAG, "WebDAV download success: $remote")
return@withContext true
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
return@withContext false
}
}
@@ -169,7 +169,7 @@ object WebDavManager {
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) {
Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
LogUtil.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
}
}
} catch (_: Exception) {
@@ -177,7 +177,7 @@ object WebDavManager {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
LogUtil.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
}
}
}
@@ -1,68 +0,0 @@
package com.v2ray.ang.helper
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class CustomDividerItemDecoration(
private val divider: Drawable,
private val orientation: Int
) : RecyclerView.ItemDecoration() {
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (orientation == RecyclerView.VERTICAL) {
drawVerticalDividers(canvas, parent)
} else {
drawHorizontalDividers(canvas, parent)
}
}
private fun drawVerticalDividers(canvas: Canvas, parent: RecyclerView) {
val left = parent.paddingLeft
val right = parent.width - parent.paddingRight
val childCount = parent.childCount
for (i in 0 until childCount - 1) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val top = child.bottom + params.bottomMargin
val bottom = top + divider.intrinsicHeight
divider.setBounds(left, top, right, bottom)
divider.draw(canvas)
}
}
private fun drawHorizontalDividers(canvas: Canvas, parent: RecyclerView) {
val top = parent.paddingTop
val bottom = parent.height - parent.paddingBottom
val childCount = parent.childCount
for (i in 0 until childCount - 1) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val left = child.right + params.rightMargin
val right = left + divider.intrinsicWidth
divider.setBounds(left, top, right, bottom)
divider.draw(canvas)
}
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
if (orientation == RecyclerView.VERTICAL) {
outRect.set(0, 0, 0, divider.intrinsicHeight)
} else {
outRect.set(0, 0, divider.intrinsicWidth, 0)
}
}
}
@@ -1,28 +1,29 @@
package com.v2ray.ang.helper
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.ComponentActivity
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) {
class FileChooserHelper(private val activity: ComponentActivity) {
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) {
if (result.resultCode == Activity.RESULT_OK && uri != null) {
fileChooserCallback?.invoke(uri)
} else {
fileChooserCallback?.invoke(null)
@@ -58,7 +59,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
fileChooserCallback?.invoke(null)
fileChooserCallback = null
@@ -79,10 +80,10 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
try {
documentCreateLauncher.launch(fileName)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "Document creator activity not found", ex)
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
documentCreateCallback?.invoke(null)
documentCreateCallback = null
}
}
}
}
@@ -1,53 +0,0 @@
/*
* Copyright (C) 2015 Paul Burke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.v2ray.ang.helper
/**
* Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
*
* @author Paul Burke (ipaulpro)
*/
interface ItemTouchHelperAdapter {
/**
* Called when an item has been dragged far enough to trigger a move. This is called every time
* an item is shifted, and **not** at the end of a "drop" event.<br></br>
* <br></br>
* Implementations should call [RecyclerView.Adapter.notifyItemMoved] after
* adjusting the underlying data to reflect this move.
*
* @param fromPosition The start position of the moved item.
* @param toPosition Then resolved position of the moved item.
* @return True if the item was moved to the new adapter position.
* @see RecyclerView.getAdapterPositionFor
* @see RecyclerView.ViewHolder.getAdapterPosition
*/
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
fun onItemMoveCompleted()
/**
* Called when an item has been dismissed by a swipe.<br></br>
* <br></br>
* Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after
* adjusting the underlying data to reflect this removal.
*
* @param position The position of the item dismissed.
* @see RecyclerView.getAdapterPositionFor
* @see RecyclerView.ViewHolder.getAdapterPosition
*/
fun onItemDismiss(position: Int)
}
@@ -1,38 +0,0 @@
/*
* Copyright (C) 2015 Paul Burke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.v2ray.ang.helper
import androidx.recyclerview.widget.ItemTouchHelper
/**
* Interface to notify an item ViewHolder of relevant callbacks from [ ].
*
* @author Paul Burke (ipaulpro)
*/
interface ItemTouchHelperViewHolder {
/**
* Called when the [ItemTouchHelper] first registers an item as being moved or swiped.
* Implementations should update the item view to indicate it's active state.
*/
fun onItemSelected()
/**
* Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
* state should be cleared.
*/
fun onItemClear()
}
@@ -0,0 +1,128 @@
package com.v2ray.ang.helper
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.service.CoreTestService
import com.v2ray.ang.service.SubscriptionUpdateService
import com.v2ray.ang.util.LogUtil
import java.io.Serializable
object MessageHelper {
/**
* Sends a message to the service.
*
* @param ctx The context.
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2Service(ctx: Context, what: Int, content: Serializable) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
}
/**
* Sends a message to the UI.
*
* @param ctx The context.
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content)
}
/**
* Sends a message to the test service.
*
* @param ctx The context.
* @param message The test service message containing key, subscriptionId, and serverGuids.
*/
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, CoreTestService::class.java)
intent.putExtra("content", message)
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(ctx, intent)
} else {
ctx.startService(intent)
}
}
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
// Do not wake up service just to cancel; stop only if it is already running.
ctx.stopService(intent)
}
else -> {
ctx.startService(intent)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
}
}
/**
* Sends a message to the subscription service.
*
* @param ctx The context.
* @param message The subscription service message containing key and subId.
*/
fun sendMsg2SubscriptionService(ctx: Context, message: SubscriptionUpdateMessage) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, SubscriptionUpdateService::class.java)
intent.putExtra("content", message)
when (message.key) {
AppConfig.MSG_SUB_UPDATE_START -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(ctx, intent)
} else {
ctx.startService(intent)
}
}
AppConfig.MSG_SUB_UPDATE_CANCEL -> {
ctx.stopService(intent)
}
else -> {
ctx.startService(intent)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to subscription service", e)
}
}
/**
* Sends a message with the specified action.
*
* @param ctx The context.
* @param action The action string.
* @param what The message identifier.
* @param content The message content.
*/
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
try {
val intent = Intent()
intent.action = action
intent.`package` = AppConfig.ANG_PACKAGE
intent.putExtra("key", what)
intent.putExtra("content", content)
ctx.sendBroadcast(intent)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
}
}
}
@@ -1,84 +0,0 @@
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
/**
* 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) {
// 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,162 @@
package com.v2ray.ang.helper
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.NotificationChannelType
/**
* Unified notification helper for different notification channels.
* Supports both regular notifications and foreground service notifications.
*
* Performance: NotificationManager is cached. Builder is created once per update.
* Safe for high-frequency updates (100+ times/second).
*/
object NotificationHelper {
// Cached instances for performance
private var cachedNotificationManager: NotificationManager? = null
private val builderCache = mutableMapOf<Int, NotificationCompat.Builder>()
/**
* Notify with a regular notification (non-foreground).
*
* @param channelType The notification channel type (defines channelId, notificationId, etc.)
* @param context The context for building the notification
* @param title The notification title
* @param content The notification content text
*/
fun notify(
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
) {
ensureChannelCreated(channelType, context)
val notificationManager = getNotificationManager(context)
val builder = buildNotificationBuilder(channelType, context, title, content)
notificationManager.notify(channelType.notificationId, builder.build())
}
/**
* Update an existing notification's content.
* Optimized for high-frequency updates (100+/sec).
* Reuses cached Builder to minimize allocation overhead.
*
* @param channelType The notification channel type
* @param context The context
* @param content The new content text
*/
fun updateNotification(
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
) {
val notificationManager = getNotificationManager(context)
// Get or create builder from cache
val builder = builderCache.getOrPut(channelType.notificationId) {
buildNotificationBuilder(channelType, context, title, content)
}
// Update only the content text (fast operation)
builder.setContentText(content)
notificationManager.notify(channelType.notificationId, builder.build())
}
/**
* Start a foreground service with a notification.
*
* @param service The service to set as foreground
* @param channelType The notification channel type
* @param title The notification title
* @param content The notification content text
*/
fun startForeground(
service: Service,
channelType: NotificationChannelType,
title: String,
content: String
) {
ensureChannelCreated(channelType, service)
val builder = buildNotificationBuilder(channelType, service, title, content)
service.startForeground(channelType.notificationId, builder.build())
}
/**
* Stop the foreground notification for a service.
*
* @param service The service to stop foreground on
*/
fun stopForeground(service: Service) {
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
}
/**
* Cancel a notification and clean up cached builder.
*
* @param channelType The notification channel type
* @param context The context
*/
fun cancel(
channelType: NotificationChannelType,
context: Context
) {
getNotificationManager(context).cancel(channelType.notificationId)
builderCache.remove(channelType.notificationId) // Clean up cache
}
// ====== Private helper methods ======
private fun getNotificationManager(context: Context): NotificationManager {
if (cachedNotificationManager == null) {
cachedNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
return cachedNotificationManager!!
}
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (notificationManager.getNotificationChannel(channelType.channelId) != null) return
val channel = NotificationChannel(
channelType.channelId,
channelType.channelName,
NotificationManager.IMPORTANCE_LOW
).apply {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
notificationManager.createNotificationChannel(channel)
}
private fun buildNotificationBuilder(
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
): NotificationCompat.Builder {
val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
channelType.channelId
} else {
""
}
val displayTitle = title.ifEmpty { context.getString(R.string.app_name) }
return NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_name)
.setContentTitle(displayTitle)
.setContentText(content)
.setOngoing(false)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
}
}
@@ -1,9 +1,9 @@
package com.v2ray.ang.helper
import android.content.pm.PackageManager
import androidx.activity.ComponentActivity
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
@@ -12,7 +12,7 @@ import com.v2ray.ang.extension.toast
/**
* Helper for requesting permissions.
*/
class PermissionHelper(private val activity: AppCompatActivity) {
class PermissionHelper(private val activity: ComponentActivity) {
private var permissionCallback: ((Boolean) -> Unit)? = null
private val permissionLauncher: ActivityResultLauncher<String> =
@@ -43,4 +43,4 @@ class PermissionHelper(private val activity: AppCompatActivity) {
permissionLauncher.launch(permission)
}
}
}
}
@@ -1,9 +1,10 @@
package com.v2ray.ang.helper
import android.app.Activity
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.ui.ScannerActivity
/**
@@ -12,12 +13,12 @@ import com.v2ray.ang.ui.ScannerActivity
* This class encapsulates the logic for launching the QR code scanner activity
* and handling the scan result.
*/
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
class QRCodeScannerHelper(private val activity: ComponentActivity) {
private var scanCallback: ((String?) -> Unit)? = null
private val scanLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
if (result.resultCode == Activity.RESULT_OK) {
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
scanCallback?.invoke(scanResult)
} else {
@@ -1,147 +0,0 @@
/*
* Copyright (C) 2015 Paul Burke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.v2ray.ang.helper
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.view.animation.DecelerateInterpolator
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.sign
/**
* An implementation of [ItemTouchHelper.Callback] that enables basic drag & drop and
* swipe-to-dismiss. Drag events are automatically started by an item long-press.<br></br>
*
* Expects the `RecyclerView.Adapter` to listen for [ ] callbacks and the `RecyclerView.ViewHolder` to implement
* [ItemTouchHelperViewHolder].
*
* @author Paul Burke (ipaulpro)
*/
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 = allowSwipe
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val dragFlags: Int
val swipeFlags: Int
if (recyclerView.layoutManager is GridLayoutManager) {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
} else {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
}
return makeMovementFlags(dragFlags, swipeFlags)
}
override fun onMove(
recyclerView: RecyclerView,
source: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return if (source.itemViewType != target.itemViewType) {
false
} else {
mAdapter.onItemMove(source.bindingAdapterPosition, target.bindingAdapterPosition)
true
}
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Do not delete; simply return item to original position
returnViewToOriginalPosition(viewHolder)
}
override fun onChildDraw(
c: Canvas, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val maxSwipeDistance = viewHolder.itemView.width * SWIPE_THRESHOLD
val swipeAmount = abs(dX)
val direction = sign(dX)
// Limit maximum swipe distance
val translationX = min(swipeAmount, maxSwipeDistance) * direction
val alpha = ALPHA_FULL - min(swipeAmount, maxSwipeDistance) / maxSwipeDistance
viewHolder.itemView.translationX = translationX
viewHolder.itemView.alpha = alpha
if (swipeAmount >= maxSwipeDistance && isCurrentlyActive) {
returnViewToOriginalPosition(viewHolder)
}
} else {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
}
private fun returnViewToOriginalPosition(viewHolder: RecyclerView.ViewHolder) {
mReturnAnimator?.takeIf { it.isRunning }?.cancel()
mReturnAnimator = ValueAnimator.ofFloat(viewHolder.itemView.translationX, 0f).apply {
addUpdateListener { animation ->
val value = animation.animatedValue as Float
viewHolder.itemView.translationX = value
viewHolder.itemView.alpha = 1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD)
}
interpolator = DecelerateInterpolator()
duration = ANIMATION_DURATION
start()
}
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (actionState != ItemTouchHelper.ACTION_STATE_IDLE && viewHolder is ItemTouchHelperViewHolder) {
viewHolder.onItemSelected()
}
super.onSelectedChanged(viewHolder, actionState)
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.alpha = ALPHA_FULL
if (viewHolder is ItemTouchHelperViewHolder) {
viewHolder.onItemClear()
}
mAdapter.onItemMoveCompleted()
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
return 1.1f // Set a value greater than 1 to prevent default swipe delete
}
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
return defaultValue * 10 // Increase swipe escape velocity to make swipe harder to trigger
}
companion object {
private const val ALPHA_FULL = 1.0f
private const val SWIPE_THRESHOLD = 0.25f
private const val ANIMATION_DURATION: Long = 200
}
}
@@ -3,39 +3,60 @@ package com.v2ray.ang.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import android.os.UserManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.core.LauncherManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.util.LogUtil
class BootReceiver : BroadcastReceiver() {
/**
* This method is called when the BroadcastReceiver is receiving an Intent broadcast.
* It checks if the context is not null and the action is ACTION_BOOT_COMPLETED.
* It handles BOOT_COMPLETED, LOCKED_BOOT_COMPLETED, and MY_PACKAGE_REPLACED.
* If the conditions are met, it starts the V2Ray service.
*
* @param context The Context in which the receiver is running.
* @param intent The Intent being received.
*/
override fun onReceive(context: Context?, intent: Intent?) {
Log.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
val action = intent?.action ?: return
if (context == null) return
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
Log.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
return
LogUtil.i(AppConfig.TAG, "BootReceiver received: $action")
when (action) {
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED -> {
// Continue
}
Intent.ACTION_LOCKED_BOOT_COMPLETED -> {
val userManager = context.getSystemService(Context.USER_SERVICE) as? UserManager
if (userManager != null && !userManager.isUserUnlocked) {
LogUtil.w(AppConfig.TAG, "BootReceiver: User is locked, skipping auto start")
return
}
}
else -> {
LogUtil.w(AppConfig.TAG, "BootReceiver: Unhandled action: $action")
return
}
}
if (!MmkvManager.decodeStartOnBoot()) {
Log.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
LogUtil.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
return
}
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
Log.w(AppConfig.TAG, "BootReceiver: No server selected")
LogUtil.w(AppConfig.TAG, "BootReceiver: No server selected")
return
}
Log.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
V2RayServiceManager.startVService(context)
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
LauncherManager.startService(context)
SubscriptionUpdater.sync(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.handler.V2RayServiceManager
import com.v2ray.ang.core.LauncherManager
import com.v2ray.ang.util.LogUtil
class TaskerReceiver : BroadcastReceiver() {
@@ -27,15 +28,15 @@ class TaskerReceiver : BroadcastReceiver() {
return
} else if (switch) {
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
V2RayServiceManager.startVServiceFromToggle(context)
LauncherManager.startServiceFromToggle(context)
} else {
V2RayServiceManager.startVService(context, guid)
LauncherManager.startService(context, guid)
}
} else {
V2RayServiceManager.stopVService(context)
LauncherManager.stopService(context)
}
} catch (e: Exception) {
android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
}
}
}
@@ -9,7 +9,8 @@ import android.content.Intent
import android.widget.RemoteViews
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.core.LauncherManager
class WidgetProvider : AppWidgetProvider() {
/**
@@ -22,7 +23,7 @@ class WidgetProvider : AppWidgetProvider() {
*/
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.isRunning())
updateWidgetBackground(context, appWidgetManager, appWidgetIds, CoreServiceManager.isRunning())
}
/**
@@ -67,10 +68,10 @@ class WidgetProvider : AppWidgetProvider() {
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(context)
if (CoreServiceManager.isRunning()) {
LauncherManager.stopService(context)
} else {
V2RayServiceManager.startVServiceFromToggle(context)
LauncherManager.startServiceFromToggle(context)
}
} else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
AppWidgetManager.getInstance(context)?.let { manager ->
@@ -0,0 +1,50 @@
package com.v2ray.ang.root
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object RootLanSharing {
private var lanSharingStarted = false
private var lanShareJob: Job? = null
/**
* Optional root feature: share the proxy with tethered LAN/USB clients while the
* device itself stays on the VpnService. Runs a dedicated client hev-socks5-tunnel
* off the main thread so the su calls don't block service startup.
* The cheap, usually-false preference is checked first so the common path
* short-circuits before touching root state.
*/
fun startClientSharing(context: Context): Boolean {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING) && RootManager.cachedRoot()) {
if (lanShareJob != null) return false
lanSharingStarted = true
lanShareJob = CoroutineScope(Dispatchers.IO).launch { RootProxyManager.startClientSharing(context) }
}
return true
}
/**
* Remove LAN/tethering sharing rules + helper before stopping the core. Wait for the
* async setup to finish first, otherwise a stop during setup tears down before the
* rules are installed and they leak (orphan FORWARD/policy-routing rules + client tun).
*/
fun stopClientSharing(context: Context) {
if (!lanSharingStarted) return
lanSharingStarted = false
runBlocking { lanShareJob?.cancelAndJoin() }
lanShareJob = null
RootProxyManager.stop(context)
}
}
@@ -0,0 +1,64 @@
package com.v2ray.ang.root
import com.v2ray.ang.AppConfig
import com.v2ray.ang.root.RootManager.isRootAvailable
import com.v2ray.ang.root.RootManager.refresh
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
/**
* Detects whether the device grants root (`su`) access.
*
* The result is cached after the first successful probe. Probing spawns `su` and
* blocks, so [refresh] (a suspending call on [Dispatchers.IO]) should be used from UI
* code; [isRootAvailable] may block and must not be called on the main thread the first time.
*/
object RootManager {
@Volatile
private var cached: Boolean? = null
/** Last known result without probing. Defaults to false when never probed. */
fun cachedRoot(): Boolean = cached ?: false
/**
* Returns whether root is available, probing once if unknown.
* May block while `su` is spawned; avoid calling on the main thread before a probe.
*/
fun isRootAvailable(forceRefresh: Boolean = false): Boolean {
if (!forceRefresh) cached?.let { return it }
val result = probe()
cached = result
return result
}
/** Probes root off the main thread, updates the cache, and returns the result. */
suspend fun refresh(): Boolean = withContext(Dispatchers.IO) {
val result = probe()
cached = result
result
}
private fun probe(): Boolean {
return try {
val process = ProcessBuilder("su", "-c", "id -u")
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }.trim()
val finished = process.waitFor(10, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
LogUtil.w(AppConfig.TAG, "RootManager: su probe timed out")
return false
}
val isRoot = process.exitValue() == 0 && output.lineSequence().lastOrNull()?.trim() == "0"
LogUtil.i(AppConfig.TAG, "RootManager: root available = $isRoot")
isRoot
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "RootManager: no root access (${e.message})")
false
}
}
}
@@ -0,0 +1,475 @@
package com.v2ray.ang.root
import android.content.Context
import android.os.Process
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.root.RootProxyManager.TABLE
import com.v2ray.ang.root.RootProxyManager.TUN
import com.v2ray.ang.root.RootProxyManager.teardown
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.PackageUidResolver
import com.v2ray.ang.util.Utils
import java.io.File
/**
* Installs and removes the iptables / ip-rule routing that pushes system-wide traffic
*
* A bundled `hev-socks5-tunnel` binary (run as root) creates a tun device and forwards it to
* the in-process core's SOCKS inbound; a mangle MARK chain plus a dedicated routing table /
* ip rule steer all traffic into the tun. Full TCP + UDP.
*
* All rules live in dedicated chains ([AppConfig.ROOT_IPTABLES_CHAIN] in the mangle
* table, [AppConfig.ROOT_FWD_CHAIN] for LAN sharing) plus a dedicated routing table, so
* [teardown] is a clean, bounded flush. Teardown runs before every setup (to clear stale
* rules) and on every stop path — leaving rules behind after the core dies would break
* the device's connectivity.
*/
object RootProxyManager {
private const val CHAIN = AppConfig.ROOT_IPTABLES_CHAIN
private const val TUN = AppConfig.ROOT_TUN_NAME
private const val TABLE = AppConfig.ROOT_ROUTE_TABLE
private const val PRIORITY = AppConfig.ROOT_RULE_PRIORITY
private const val FWMARK = AppConfig.ROOT_FWMARK
private const val MARK = AppConfig.ROOT_MARK_ROUTE
// Local / private / multicast destinations that must never be proxied.
private val bypassCidrs = listOf(
"0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4", "240.0.0.0/4"
)
// IPv6 equivalents (loopback, link-local, ULA/private, multicast). Feeding the v4 list
// above to ip6tables silently fails, so the v6 chain needs its own.
private val bypassCidrsV6 = listOf(
"::1/128", "fe80::/10", "fc00::/7", "ff00::/8"
)
fun start(context: Context): Boolean {
teardown(context)
val script = buildTun2socksSetup(context) ?: return false
val result = RootShell.runScript(context, "setup_rules.sh", script)
if (!result.success) {
LogUtil.e(AppConfig.TAG, "RootProxyManager: setup failed, rolling back:\n${result.output}")
teardown(context)
return false
}
return true
}
/**
* Set up LAN/tethering sharing while the device itself uses another mode (e.g. VPN
* mode). Runs a dedicated client tun2socks into the in-process core's SOCKS inbound
* and forwards tethered clients into it, WITHOUT capturing the device's own traffic
* (that keeps flowing through the VpnService). Requires root.
*/
fun startClientSharing(context: Context): Boolean {
teardown(context)
val script = buildTun2socksSetup(context, captureDeviceTraffic = false, forceLanShare = true)
?: return false
val result = RootShell.runScript(context, "setup_rules.sh", script)
if (!result.success) {
LogUtil.e(AppConfig.TAG, "RootProxyManager: client sharing setup failed:\n${result.output}")
teardown(context)
return false
}
LogUtil.i(AppConfig.TAG, "RootProxyManager: LAN client sharing installed")
return true
}
/** Remove all rules and stop helper processes. Safe to call repeatedly. */
fun stop(context: Context) {
teardown(context)
LogUtil.i(AppConfig.TAG, "RootProxyManager: rules removed")
}
private fun teardown(context: Context) {
RootShell.runScript(context, "teardown_rules.sh", buildTeardown(context))
}
// --------------------------------------------------------------- TUN2SOCKS
/**
* @param captureDeviceTraffic when true (Root mode) the device's own OUTPUT traffic is
* marked into the tun. When false (VPN-mode LAN sharing) the device keeps using the
* VpnService and only forwarded clients are routed into this tun.
* @param forceLanShare force the LAN/tethering forward rules on regardless of the pref
* (used by VPN-mode sharing, where the whole point is forwarding clients).
*/
private fun buildTun2socksSetup(
context: Context,
captureDeviceTraffic: Boolean = true,
forceLanShare: Boolean = false,
): String? {
val bin = File(context.applicationInfo.nativeLibraryDir, AppConfig.ROOT_TUN2SOCKS_BIN)
if (!bin.exists()) {
LogUtil.e(AppConfig.TAG, "RootProxyManager: hev-socks5-tunnel binary missing at ${bin.absolutePath}")
return null
}
val appUid = context.applicationInfo.uid
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val port = SettingsManager.getSocksPort()
val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
val pidFile = File(runDir, "tun2socks.pid").absolutePath
val logFile = File(runDir, "tun2socks.log").absolutePath
val cfgFile = File(runDir, "tun2socks.yml").absolutePath
val oomGuardPid = File(runDir, "oomguard.pid").absolutePath
val ipv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)
val lanShare = forceLanShare || MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
val corePid = Process.myPid()
// Per-app proxy/bypass (mirrors what VpnService does via allowed/disallowed apps).
val perAppEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
val selectedUids = if (perAppEnabled) {
val pkgs = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.toList().orEmpty()
if (pkgs.isNotEmpty()) PackageUidResolver.packageNamesToUids(context, pkgs) else emptyList()
} else {
emptyList()
}
return buildString {
appendLine("set -e")
appendLine("BIN='${bin.absolutePath}'")
// Protect the core (this app process) from the Android low-memory killer.
// system_server keeps recomputing oom_score_adj for app processes, so a single
// write would be reverted — re-pin it from a small root loop instead.
appendLine("nohup sh -c 'while true; do echo ${AppConfig.ROOT_OOM_SCORE} > /proc/$corePid/oom_score_adj 2>/dev/null; sleep 5; done' >/dev/null 2>&1 &")
appendLine("echo \$! > '$oomGuardPid'")
// tun device node
appendLine("if [ ! -e /dev/net/tun ]; then mkdir -p /dev/net; mknod /dev/net/tun c 10 200; chmod 666 /dev/net/tun; fi")
// hev-socks5-tunnel config: it creates the tun ($TUN) itself and forwards it to the
// in-process core's SOCKS inbound on loopback. MTU comes from the existing VPN MTU
// setting. No fwmark on hev's sockets: its only upstream connection is to 127.0.0.1
// (loopback, already RETURNed by the 127.0.0.0/8 bypass) and the core's real outbound
// runs as the app uid (RETURNed by the uid-owner rule), so traffic can't loop.
appendLine("cat > '$cfgFile' <<'HEVCFG'")
append(buildHevConfig(socksUsername = socksUsername, socksPassword = socksPassword, socksPort = port, ipv6 = ipv6))
appendLine("HEVCFG")
appendLine("nohup \"\$BIN\" '$cfgFile' >'$logFile' 2>&1 &")
appendLine("T2S_PID=\$!")
appendLine("echo \$T2S_PID > '$pidFile'")
appendLine("echo ${AppConfig.ROOT_OOM_SCORE} > /proc/\$T2S_PID/oom_score_adj 2>/dev/null || true")
// wait for the interface hev creates to appear
appendLine("i=0; while [ \$i -lt 20 ]; do ip link show $TUN >/dev/null 2>&1 && break; sleep 0.3; i=\$((i+1)); done")
appendLine("ip link show $TUN >/dev/null 2>&1 || { echo 'tun device did not come up'; cat '$logFile' 2>/dev/null; exit 1; }")
// relax reverse-path filtering for the tun
appendLine("echo 0 > /proc/sys/net/ipv4/conf/$TUN/rp_filter 2>/dev/null || true")
appendLine("echo 0 > /proc/sys/net/ipv4/conf/all/rp_filter 2>/dev/null || true")
// address + default route in a dedicated table
appendLine("ip addr add ${AppConfig.ROOT_TUN_ADDR_V4} dev $TUN 2>/dev/null || true")
appendLine("ip link set dev $TUN up")
appendLine("ip route replace default dev $TUN table $TABLE")
appendLine("ip rule add fwmark $MARK table $TABLE priority $PRIORITY")
// mark the device's own packets into the tun (Root mode only)
if (captureDeviceTraffic) {
append(buildMangleMarking("iptables", appUid, perAppEnabled, bypassApps, selectedUids))
}
// optionally route hotspot / USB-tethered clients through the tun too
if (lanShare) {
append(buildLanShareSetup(captureDeviceTraffic, ipv6))
}
if (captureDeviceTraffic) {
// IPv6 is best-effort: never fail the (working) IPv4 setup over it.
appendLine("set +e")
if (ipv6) {
// route the device's v6 into the tun, same as v4
appendLine("ip -6 addr add ${AppConfig.ROOT_TUN_ADDR_V6} dev $TUN 2>/dev/null || true")
appendLine("ip -6 route replace default dev $TUN table $TABLE 2>/dev/null || true")
appendLine("ip -6 rule add fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
append(buildMangleMarking("ip6tables", appUid, perAppEnabled, bypassApps, selectedUids))
} else {
// v6 disabled: blackhole native v6 egress for the captured apps so they
// fall back to v4-through-proxy, matching what a v4-only VpnService does.
append(buildV6Blackhole(appUid, perAppEnabled, bypassApps, selectedUids))
}
}
}
}
/**
* hev-socks5-tunnel YAML config. hev creates the tun device named [TUN] itself, assigns it
* the tun addresses, and forwards everything it receives to the core's SOCKS inbound on
* loopback (TCP + UDP). MTU is taken from the existing VPN MTU setting. v6 is only given a
* tun address when IPv6 is enabled; whether v6 actually flows in is decided separately by
* the v6 route into [TABLE].
*/
private fun buildHevConfig(socksUsername: String?, socksPassword: String?, socksPort: Int, ipv6: Boolean): String {
val v4 = AppConfig.ROOT_TUN_ADDR_V4.substringBefore("/")
val v6 = AppConfig.ROOT_TUN_ADDR_V6.substringBefore("/")
return buildString {
appendLine("tunnel:")
appendLine(" name: '$TUN'")
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
appendLine(" multi-queue: true")
appendLine(" ipv4: '$v4'")
if (ipv6) appendLine(" ipv6: '$v6'")
appendLine("socks5:")
appendLine(" port: $socksPort")
appendLine(" address: '${AppConfig.LOOPBACK}'")
appendLine(" udp: 'udp'")
if (socksUsername != null && socksPassword != null) {
appendLine(" username: '$socksUsername'")
appendLine(" password: '$socksPassword'")
}
appendLine(" tcp-fastopen: true")
}
}
/**
* mangle OUTPUT marking chain (ipv4/ipv6). Mirrors VpnService's capture behavior:
* - all-apps (no per-app): mark EVERY remaining uid (incl uid 0 + all system uids), so
* nothing is missed;
* - bypass mode: the selected apps go fully direct, everything else is captured;
* - proxy mode: only the selected apps are captured.
*/
private fun buildMangleMarking(
cmd: String,
appUid: Int,
perAppEnabled: Boolean,
bypassApps: Boolean,
selectedUids: List<String>,
): String {
val allowMode = perAppEnabled && !bypassApps
val bypassSelected = perAppEnabled && bypassApps && selectedUids.isNotEmpty()
return buildString {
appendLine("$cmd -t mangle -N $CHAIN 2>/dev/null || true")
appendLine("$cmd -t mangle -F $CHAIN")
// the app's own core traffic (the real outbound) must not loop back into the tun.
// The $FWMARK RETURN is kept defensively (hev itself only talks to loopback, which
// the 127.0.0.0/8 bypass below already RETURNs).
appendLine("$cmd -t mangle -A $CHAIN -m mark --mark $FWMARK -j RETURN")
appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $appUid -j RETURN")
// bypass mode: selected apps go fully direct (incl their DNS)
if (bypassSelected) {
selectedUids.forEach { appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $it -j RETURN") }
}
// Route DNS through the core for ALL modes, with no uid filter. On Android the
// DNS query is sent by netd (a shared system uid) on behalf of the app, not under
// the app's own uid, so it can't be attributed to a selected uid via owner-match.
// This MUST also run before the LAN-bypass RETURNs below, otherwise a query to a
// LAN/router resolver (192.168.x / 10.x) would be returned direct and resolved by
// the local ISP resolver (DNS leak + CDN mis-resolution, e.g. Instagram media).
// The MARK survives a later RETURN, so the marked query still routes into the tun.
appendLine("$cmd -t mangle -A $CHAIN -p udp --dport 53 -j MARK --set-xmark $MARK")
appendLine("$cmd -t mangle -A $CHAIN -p tcp --dport 53 -j MARK --set-xmark $MARK")
// keep LAN / private destinations direct (per-family CIDR list)
val cidrs = if (cmd == "ip6tables") bypassCidrsV6 else bypassCidrs
cidrs.forEach { appendLine("$cmd -t mangle -A $CHAIN -d $it -j RETURN") }
if (allowMode) {
// Proxy ONLY the explicitly selected apps. If nothing resolved (e.g. the
// selected packages failed to resolve to uids at early boot), mark nothing
// instead of falling through to the catch-all below: a fail-open here would
// tunnel every unselected app — both a privacy leak and the "per-app proxies
// everything after a reboot" bug.
selectedUids.forEach { appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $it -j MARK --set-xmark $MARK") }
} else {
// all-apps mode (per-app off) or bypass mode: capture EVERY remaining uid
// (incl uid 0 + system uids)
appendLine("$cmd -t mangle -A $CHAIN -j MARK --set-xmark $MARK")
}
appendLine("$cmd -t mangle -D OUTPUT -j $CHAIN 2>/dev/null || true")
appendLine("$cmd -t mangle -A OUTPUT -j $CHAIN")
}
}
/**
* Blackhole native IPv6 egress for the captured app population when IPv6 is NOT routed
* into the tun. A v4-only VpnService has no v6 route, so the kernel rejects apps' v6 and
* they fall back to IPv4; Root mode has to reproduce that explicitly, otherwise v6-capable
* apps reach destinations natively, bypassing the proxy / leaking. REJECT (not DROP) gives
* an instant failure so happy-eyeballs falls back to v4 without a timeout.
*
* Exemptions mirror the v4 chain: the tun2socks helper (fwmark), the app's own core (uid),
* loopback, link-local / multicast (NDP/RA/MLD) and ULA/LAN destinations. Per-app selection
* is honored: in bypass mode the bypassed apps keep native v6; in proxy mode only the
* selected apps lose v6 (everything else stays fully direct).
*/
private fun buildV6Blackhole(
appUid: Int,
perAppEnabled: Boolean,
bypassApps: Boolean,
selectedUids: List<String>,
): String {
val chain = AppConfig.ROOT_V6_CHAIN
val allowMode = perAppEnabled && !bypassApps
val bypassSelected = perAppEnabled && bypassApps && selectedUids.isNotEmpty()
val reject = "-j REJECT --reject-with icmp6-adm-prohibited"
return buildString {
appendLine("ip6tables -t filter -N $chain 2>/dev/null || true")
appendLine("ip6tables -t filter -F $chain")
// never touch the helper, the core, loopback, NDP/link-local/multicast or LAN
appendLine("ip6tables -t filter -A $chain -m mark --mark $FWMARK -j RETURN")
appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $appUid -j RETURN")
appendLine("ip6tables -t filter -A $chain -o lo -j RETURN")
bypassCidrsV6.forEach { appendLine("ip6tables -t filter -A $chain -d $it -j RETURN") }
// bypass mode: bypassed apps keep their native v6
if (bypassSelected) {
selectedUids.forEach { appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $it -j RETURN") }
}
if (allowMode) {
// proxy mode: only the selected apps lose v6 (so they fall back to v4-via-proxy).
// None resolved -> reject nothing, mirroring the v4 chain's fail-closed handling.
selectedUids.forEach { appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $it $reject") }
} else {
// all-apps / bypass: reject everyone left
appendLine("ip6tables -t filter -A $chain $reject")
}
appendLine("ip6tables -t filter -D OUTPUT -j $chain 2>/dev/null || true")
appendLine("ip6tables -t filter -A OUTPUT -j $chain")
}
}
// -------------------------------------------------- LAN / tethering sharing
/**
* Route Wi-Fi-hotspot / USB-tethered clients through the tun as well (ipv4).
* Best-effort: wrapped in `set +e` so a failure here never breaks the working proxy.
* Mirrors Magic_V2Ray's hotspot rules (FORWARD accept, DNS DNAT, source-based policy
* routing for private client ranges, MSS clamp).
*/
private fun buildLanShareSetup(captureDeviceTraffic: Boolean, ipv6: Boolean): String {
val fwd = AppConfig.ROOT_FWD_CHAIN
val dnsChain = AppConfig.ROOT_DNS_CHAIN
val v6fwd = AppConfig.ROOT_V6_FWD_CHAIN
val v6pre = AppConfig.ROOT_V6_PRE_CHAIN
// Use the app's configured remote DNS (first plain IPv4) as the DNAT target for
// tethered clients; fall back to the default when it's a DoH/DoT/IPv6 value that
// can't be a DNAT target.
val dns = SettingsManager.getRemoteDnsServers()
.firstOrNull { Utils.isPureIpAddress(it) && !it.contains(":") }
?: AppConfig.ROOT_LAN_DNS
val lanCidrs = listOf("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
return buildString {
appendLine("set +e")
appendLine("echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true")
// forward traffic to/from the tun
appendLine("iptables -N $fwd 2>/dev/null || true")
appendLine("iptables -F $fwd")
appendLine("iptables -A $fwd -i $TUN -j ACCEPT")
appendLine("iptables -A $fwd -o $TUN -j ACCEPT")
appendLine("iptables -D FORWARD -j $fwd 2>/dev/null || true")
appendLine("iptables -I FORWARD -j $fwd")
// clamp MSS to avoid TLS fragmentation overhead through the tunnel
appendLine("iptables -t mangle -D FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350 2>/dev/null || true")
appendLine("iptables -t mangle -A FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350")
// hijack tethered clients' DNS into a dedicated chain so it resolves through the
// tunnel; the chain keeps teardown independent of the resolver IP
appendLine("iptables -t nat -N $dnsChain 2>/dev/null || true")
appendLine("iptables -t nat -F $dnsChain")
lanCidrs.forEach {
appendLine("iptables -t nat -A $dnsChain ! -i $TUN -d $it -p udp --dport 53 -j DNAT --to $dns")
}
appendLine("iptables -t nat -D PREROUTING -j $dnsChain 2>/dev/null || true")
appendLine("iptables -t nat -A PREROUTING -j $dnsChain")
// policy routing: return-path via main, LAN direct, the rest via the tun table
appendLine("ip rule add iif lo goto 6000 pref 5000 2>/dev/null || true")
appendLine("ip rule add iif $TUN lookup main suppress_prefixlength 0 pref 5010 2>/dev/null || true")
appendLine("ip rule add iif $TUN goto 6000 pref 5020 2>/dev/null || true")
appendLine("ip rule add to 10.0.0.0/8 lookup main pref 5025 2>/dev/null || true")
appendLine("ip rule add to 172.16.0.0/12 lookup main pref 5026 2>/dev/null || true")
appendLine("ip rule add to 192.168.0.0/16 lookup main pref 5027 2>/dev/null || true")
appendLine("ip rule add from 10.0.0.0/8 lookup $TABLE pref 5030 2>/dev/null || true")
appendLine("ip rule add from 172.16.0.0/12 lookup $TABLE pref 5040 2>/dev/null || true")
appendLine("ip rule add from 192.168.0.0/16 lookup $TABLE pref 5050 2>/dev/null || true")
appendLine("ip rule add nop pref 6000 2>/dev/null || true")
// ---------------------------------------------------------- IPv6 clients
// Tethered/hotspot clients get a native (RA-assigned) global IPv6. The IPv4 rules
// above don't touch it, so it egresses the upstream interface directly, bypassing
// the proxy = IPv6 leak. Handle it explicitly (mirrors vincentng295/Magic_V2Ray
// cae4f7f): route it through the tun when v6 is enabled, reject it when it isn't.
appendLine("ip6tables -N $v6fwd 2>/dev/null || true")
appendLine("ip6tables -F $v6fwd")
appendLine("ip6tables -D FORWARD -j $v6fwd 2>/dev/null || true")
appendLine("ip6tables -I FORWARD -j $v6fwd")
if (ipv6) {
// When the device itself isn't capturing v6 (VPN-mode sharing) the tun table
// has no v6 default and the tun has no v6 address — add them so marked client
// v6 has somewhere to go. In Root mode the device-capture block already did.
if (!captureDeviceTraffic) {
appendLine("ip -6 addr add ${AppConfig.ROOT_TUN_ADDR_V6} dev $TUN 2>/dev/null || true")
appendLine("ip -6 route replace default dev $TUN table $TABLE 2>/dev/null || true")
appendLine("ip -6 rule add fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
}
// allow forwarding to/from the tun
appendLine("ip6tables -A $v6fwd -i $TUN -j ACCEPT")
appendLine("ip6tables -A $v6fwd -o $TUN -j ACCEPT")
// mark forwarded (non-locally-sourced) client v6 into the tun table. DNS first
// so a query to a LAN/router resolver is still tunneled (MARK survives RETURN);
// keep loopback, link-local (NDP/RA) and ULA/multicast direct.
appendLine("ip6tables -t mangle -N $v6pre 2>/dev/null || true")
appendLine("ip6tables -t mangle -F $v6pre")
appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -p udp --dport 53 -j MARK --set-xmark $MARK")
appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -p tcp --dport 53 -j MARK --set-xmark $MARK")
bypassCidrsV6.forEach { appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -d $it -j RETURN") }
appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -j MARK --set-xmark $MARK")
appendLine("ip6tables -t mangle -D PREROUTING -j $v6pre 2>/dev/null || true")
appendLine("ip6tables -t mangle -A PREROUTING -j $v6pre")
// fail closed: any forwarded v6 that wasn't marked into the tun (e.g. the
// addrtype match is unavailable, or marking failed) is rejected rather than
// leaked straight out the upstream interface.
appendLine("ip6tables -A $v6fwd -j REJECT --reject-with icmp6-no-route")
} else {
// v6 disabled: reject forwarded clients' native v6 so it can't leak past the
// proxy (the device's own v6 is blackholed separately in OUTPUT).
appendLine("ip6tables -A $v6fwd -j REJECT --reject-with icmp6-no-route")
}
}
}
// ---------------------------------------------------------------- teardown
private fun buildTeardown(context: Context): String {
val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR)
val pidFile = File(runDir, "tun2socks.pid").absolutePath
val oomGuardPid = File(runDir, "oomguard.pid").absolutePath
val corePid = Process.myPid()
return buildString {
// mangle (TUN2SOCKS), both families
for (cmd in listOf("iptables", "ip6tables")) {
appendLine("$cmd -t mangle -D OUTPUT -j $CHAIN 2>/dev/null || true")
appendLine("$cmd -t mangle -F $CHAIN 2>/dev/null || true")
appendLine("$cmd -t mangle -X $CHAIN 2>/dev/null || true")
}
// IPv6 blackhole chain (only set up when v6 is disabled; harmless if absent)
appendLine("ip6tables -t filter -D OUTPUT -j ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true")
appendLine("ip6tables -t filter -F ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true")
appendLine("ip6tables -t filter -X ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true")
// routing rule + table
appendLine("ip rule del fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
appendLine("ip -6 rule del fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
appendLine("ip route flush table $TABLE 2>/dev/null || true")
appendLine("ip -6 route flush table $TABLE 2>/dev/null || true")
// LAN / tethering sharing (always cleaned, harmless if it was never set up)
appendLine("iptables -D FORWARD -j ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true")
appendLine("iptables -F ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true")
appendLine("iptables -X ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true")
appendLine("iptables -t mangle -D FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350 2>/dev/null || true")
appendLine("iptables -t nat -D PREROUTING -j ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
appendLine("iptables -t nat -F ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
appendLine("iptables -t nat -X ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
// IPv6 LAN-sharing chains (forward accept/reject + forwarded-client marking)
appendLine("ip6tables -D FORWARD -j ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true")
appendLine("ip6tables -F ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true")
appendLine("ip6tables -X ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true")
appendLine("ip6tables -t mangle -D PREROUTING -j ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true")
appendLine("ip6tables -t mangle -F ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true")
appendLine("ip6tables -t mangle -X ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true")
for (pref in listOf(5000, 5010, 5020, 5025, 5026, 5027, 5030, 5040, 5050, 6000)) {
appendLine("ip rule del pref $pref 2>/dev/null || true")
}
// tun device down + helper process
appendLine("ip link set dev $TUN down 2>/dev/null || true")
appendLine("[ -f '$pidFile' ] && kill \$(cat '$pidFile') 2>/dev/null || true")
appendLine("rm -f '$pidFile'")
// stop the OOM re-pin loop and restore the core process's LMK priority
appendLine("[ -f '$oomGuardPid' ] && kill \$(cat '$oomGuardPid') 2>/dev/null || true")
appendLine("rm -f '$oomGuardPid'")
appendLine("echo 0 > /proc/$corePid/oom_score_adj 2>/dev/null || true")
}
}
}
@@ -0,0 +1,55 @@
package com.v2ray.ang.root
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Minimal root command runner backed by the `su` binary.
*
* Scripts are written to the app's private root runtime dir and executed with
* `su -c sh <file>` so shell quoting stays simple. stderr is merged into stdout to
* avoid pipe-buffer deadlocks.
*/
object RootShell {
data class Result(val code: Int, val output: String) {
val success: Boolean get() = code == 0
}
/** Write [script] to `<filesDir>/root/<name>` and run it as root. */
fun runScript(context: Context, name: String, script: String): Result {
val dir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
val file = File(dir, name).apply {
writeText(script)
setExecutable(true, false)
}
val safePath = file.absolutePath.replace("'", "'\\''")
return exec("sh '$safePath'")
}
private fun exec(command: String, timeoutSeconds: Long = 30): Result {
return try {
val process = ProcessBuilder("su", "-c", command)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
LogUtil.e(AppConfig.TAG, "RootShell: timed out: $command")
return Result(-1, output)
}
val result = Result(process.exitValue(), output)
if (!result.success) {
LogUtil.w(AppConfig.TAG, "RootShell: '$command' exited ${result.code}: ${output.trim()}")
}
result
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "RootShell: failed to run '$command'", e)
Result(-1, e.message ?: e.javaClass.simpleName)
}
}
}
@@ -4,22 +4,23 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.core.CoreServiceManager
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 java.lang.ref.SoftReference
class V2RayProxyOnlyService : Service(), ServiceControl {
class CoreProxyOnlyService : Service(), ServiceControl {
/**
* Initializes the service.
*/
override fun onCreate() {
super.onCreate()
Log.i(AppConfig.TAG, "StartCore-Proxy: Service created")
V2RayServiceManager.serviceControl = SoftReference(this)
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
CoreServiceManager.serviceControl = SoftReference(this)
}
/**
@@ -30,8 +31,15 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
V2RayServiceManager.startCoreLoop(null)
NotificationManager.ensureForeground()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
if (!CoreServiceManager.startCoreLoop(null)) {
LogUtil.e(AppConfig.TAG, "StartCore-Proxy: Failed to start core loop")
stopSelf()
return START_NOT_STICKY
}
return START_STICKY
}
@@ -40,7 +48,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onDestroy() {
super.onDestroy()
V2RayServiceManager.stopCoreLoop()
CoreServiceManager.stopCoreLoop()
}
/**
@@ -0,0 +1,98 @@
package com.v2ray.ang.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.root.RootProxyManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.lang.ref.SoftReference
/**
* Foreground service for the root (system-wide) run modes. Unlike [CoreVpnService] it
* does not use Android VpnService — traffic is routed by iptables instead
* (see [RootProxyManager]).
*
* The in-process core is started first (so its listener is up and the foreground
* notification is posted promptly), then the root routing rules are installed off the
* main thread. On teardown the rules are removed before the core stops.
*/
class CoreRootService : Service(), ServiceControl {
private var setupJob: Job? = null
override fun onCreate() {
super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-Root: Service created")
CoreServiceManager.serviceControl = SoftReference(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
NotificationManager.ensureForeground()
LogUtil.i(AppConfig.TAG, "StartCore-Root: command received")
// Start the in-process core first (this also posts the foreground notification),
// then install the root routing off the main thread.
if (!CoreServiceManager.startCoreLoop(null)) {
LogUtil.e(AppConfig.TAG, "StartCore-Root: core failed to start")
stopService()
return START_NOT_STICKY
}
setupJob = CoroutineScope(Dispatchers.IO).launch {
if (!RootProxyManager.start(this@CoreRootService)) {
LogUtil.e(AppConfig.TAG, "StartCore-Root: failed to start root mode, stopping")
stopService()
}
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
// Wait for any in-flight async setup to finish before tearing down. The rules are
// installed off the main thread and can take seconds (the setup script waits for the
// tun to appear); if a stop arrives during that window, teardown would run first and
// the setup would then re-install the rules + tun pointing at a now-dead core,
// blackholing all traffic until the next start/stop cycle clears it.
runBlocking { setupJob?.cancelAndJoin() }
// Remove routing rules BEFORE stopping the core so traffic is never redirected
// to a dead listener. Synchronous on purpose — leaving rules behind breaks the net.
RootProxyManager.stop(this)
CoreServiceManager.stopCoreLoop()
}
override fun getService(): Service = this
override fun startService() {
// do nothing
}
override fun stopService() {
stopSelf()
}
override fun vpnProtect(socket: Int): Boolean = true
override fun onBind(intent: Intent?): IBinder? = null
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
super.attachBaseContext(context)
}
}
@@ -0,0 +1,145 @@
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.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.util.LogUtil
import java.util.Collections
class CoreTestService : Service() {
// 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()
CoreNativeManager.initCoreEnv(this)
}
/**
* Binds the service.
* @param intent The intent.
* @return The binder.
*/
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* Cleans up resources when the service is destroyed.
*/
override fun onDestroy() {
LogUtil.i(AppConfig.TAG, "CoreTestService is being destroyed, cancelling ${activeWorkers.size} active workers")
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
NotificationHelper.stopForeground(this)
super.onDestroy()
}
/**
* 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 {
NotificationHelper.startForeground(
this,
NotificationChannelType.CORE_TEST,
getString(R.string.app_name),
getString(R.string.title_real_ping_all_server)
)
val message = intent?.serializable<TestServiceMessage>("content")
if (message == null) {
stopSelf(startId)
return START_NOT_STICKY
}
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(message, startId)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> handleMeasureCancel()
else -> {
NotificationHelper.stopForeground(this); stopSelf(startId)
}
}
return START_NOT_STICKY
}
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
val guidsList = when {
message.serverGuids.isNotEmpty() -> message.serverGuids
message.subscriptionId.isNotEmpty() -> MmkvManager.decodeServerList(message.subscriptionId)
else -> MmkvManager.decodeAllServerList()
}
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(
context = this,
guids = guidsList,
onlyTcp = message.onlyTcp,
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
)
activeWorkers.add(worker)
worker.start()
} else {
NotificationHelper.stopForeground(this)
stopSelf(startId)
}
}
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
channelType = NotificationChannelType.CORE_TEST,
context = this,
title = getString(R.string.app_name),
content = getString(R.string.connection_runing_task_left, event.text)
)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
}
is RealPingEvent.Finish -> {
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
onWorkerDone()
if (activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this)
stopSelf()
}
}
}
}
private fun handleMeasureCancel() {
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
NotificationHelper.stopForeground(this)
stopSelf()
}
}
@@ -5,83 +5,45 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.ProxyInfo
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.LOOPBACK
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.core.CoreServiceManager
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.root.RootLanSharing
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
import java.util.concurrent.atomic.AtomicBoolean
@SuppressLint("VpnServicePolicy")
class V2RayVpnService : VpnService(), ServiceControl {
class CoreVpnService : VpnService(), ServiceControl {
private lateinit var mInterface: ParcelFileDescriptor
private var isRunning = false
private var tun2SocksService: Tun2SocksControl? = null
/**destroy
* Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
*
* This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
* satisfies default network capabilities but only THE default network. Unfortunately we need to have
* android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
*
* Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
*/
@delegate:RequiresApi(Build.VERSION_CODES.P)
private val defaultNetworkRequest by lazy {
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
.build()
}
private val connectivity by lazy { getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager }
@delegate:RequiresApi(Build.VERSION_CODES.P)
private val defaultNetworkCallback by lazy {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
setUnderlyingNetworks(arrayOf(network))
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
// it's a good idea to refresh capabilities
setUnderlyingNetworks(arrayOf(network))
}
override fun onLost(network: Network) {
setUnderlyingNetworks(null)
}
}
}
private val isStartingLock = AtomicBoolean(false)
override fun onCreate() {
super.onCreate()
Log.i(AppConfig.TAG, "StartCore-VPN: Service created")
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this)
CoreServiceManager.serviceControl = SoftReference(this)
}
override fun onRevoke() {
Log.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
stopAllService()
}
@@ -92,16 +54,47 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onDestroy() {
super.onDestroy()
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
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)
}
}
unlockStart()
NotificationManager.cancelNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(AppConfig.TAG, "StartCore-VPN: Service command received")
setupVpnService()
NotificationManager.ensureForeground()
// Always-on VPN restarts from OS deliver intent.action == SERVICE_INTERFACE or null intent.
// Reset any stuck start lock left by a killed process to allow setupVpnService() to run.
val isSystemVpnStart = intent == null || intent.action == SERVICE_INTERFACE
if (isSystemVpnStart) {
unlockStart()
}
if (!tryLockStart()) {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Start already in progress")
return START_NOT_STICKY
}
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received, systemVpnStart=$isSystemVpnStart")
if (!setupVpnService()) {
unlockStart()
// Stop service if setup fails to avoid infinite restart loops (START_STICKY)
stopSelf()
return START_NOT_STICKY
}
startService()
return START_STICKY
//return super.onStartCommand(intent, flags, startId)
}
override fun getService(): Service {
@@ -110,14 +103,17 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun startService() {
if (!::mInterface.isInitialized) {
Log.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
return
}
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
if (!CoreServiceManager.startCoreLoop(mInterface)) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
stopAllService()
return
}
// Start LAN sharing if enabled in settings
RootLanSharing.startClientSharing(this)
}
override fun stopService() {
@@ -128,6 +124,10 @@ class V2RayVpnService : VpnService(), ServiceControl {
return protect(socket)
}
override fun setUnderlyingNetworks(networks: Array<Network>?): Boolean {
return super<VpnService>.setUnderlyingNetworks(networks)
}
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
@@ -139,21 +139,20 @@ class V2RayVpnService : VpnService(), ServiceControl {
* Sets up the VPN service.
* Prepares the VPN and configures it if preparation is successful.
*/
private fun setupVpnService() {
private fun setupVpnService(): Boolean {
val prepare = prepare(this)
if (prepare != null) {
Log.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
stopSelf()
return
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
return false
}
if (configureVpnService() != true) {
Log.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
stopSelf()
return
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
return false
}
runTun2socks()
return true
}
/**
@@ -175,7 +174,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
mInterface.close()
}
} catch (e: Exception) {
Log.w(AppConfig.TAG, "Failed to close old interface", e)
LogUtil.w(AppConfig.TAG, "Failed to close old interface", e)
}
// Configure platform-specific features
@@ -187,7 +186,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
isRunning = true
return true
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
LogUtil.e(AppConfig.TAG, "Failed to establish VPN interface", e)
stopAllService()
}
return false
@@ -218,7 +217,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
// Configure IPv6 if enabled
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
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
@@ -238,7 +237,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
}
builder.setSession(V2RayServiceManager.getRunningServerName())
//builder.setSession(V2RayServiceManager.getRunningServerName())
}
/**
@@ -247,15 +246,6 @@ class V2RayVpnService : VpnService(), ServiceControl {
* @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) {
Log.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)
@@ -305,7 +295,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
builder.addAllowedApplication(it)
}
} catch (e: PackageManager.NameNotFoundException) {
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
}
}
}
@@ -334,19 +324,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
// val emptyInfo = VpnNetworkInfo()
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
// saveVpnNetworkInfo(configName, info)
unlockStart()
isRunning = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
} catch (e: Exception) {
Log.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
}
}
tun2SocksService?.stopTun2Socks()
tun2SocksService = null
V2RayServiceManager.stopCoreLoop()
RootLanSharing.stopClientSharing(this)
CoreServiceManager.stopCoreLoop()
if (isForced) {
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
@@ -356,14 +342,33 @@ 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 {
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) {
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
}
}
}
}
fun tryLockStart(): Boolean {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: tryLockStart: ${isStartingLock.get()}")
return isStartingLock.compareAndSet(false, true)
}
fun unlockStart() {
isStartingLock.set(false)
LogUtil.w(AppConfig.TAG, "StartCore-VPN: unlockStart")
}
}
@@ -0,0 +1,775 @@
package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.IDialerService
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.Buffer
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.json.JSONArray
import org.json.JSONObject
import java.io.EOFException
import java.net.SocketException
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
// This class is responsible for forwarding xray's HTTP/WS requests through okhttp,
// so that its TLS and traffic characteristics are okhttp instead of golang/utls.
// Only WS and xhttp package-up are supported for now.
// ws:
// DialerNativeService connects to the control WebSocket provided by the xray core.
// Then xray sends a task message with method "WS" and the server URL to the control WebSocket.
// DialerNativeService opens a WebSocket connection to the server URL, and forwards messages between the control WebSocket and the target WebSocket.
// xhttp(package-up):
// A task message with streaming down (method == "GET" and streamResponse == true), let's call it Task A.
// A task message with unary down (streamResponse == false), let's call it Task B.
// 1. DialerNativeService connects to the control WebSocket provided by the xray core.
// 2. Xray sends Task A, DialerNativeService sends "ok" and connects to the target URL, then sends the "GET" request to the server; let's call this "Connection A".
// 3. Xray sends a Task B, called B_1. DialerNativeService sends "ok" and sends the data body to the target URL. Whatever the response is, DialerNativeService sends "ok" or "fail" back to the control WebSocket and closes the connection for Task B_1.
// 4. The server returns the response for Task B_1 through Connection A, and DialerNativeService forwards the response body to the control WebSocket.
// 5. Xray sends another Task B, called B_2.
// ...
// Finally, the xray client core sends all data through the B_1, B_2, ... tasks.
// The server closes Connection A, and DialerNativeService closes Task A.
// The above is a complete cycle.
class DialerNativeService : IDialerService {
companion object {
private const val DEBUG_LOG = false
private val NEXT_SOCKET_ID = AtomicLong(0L)
private const val CONTROL_SOCKET_IDLE = 0
private const val CONTROL_SOCKET_OPENING = 1
private const val CONTROL_LOOP_DELAY_MS = 1000L
private const val UNARY_BODY_WAIT_TIMEOUT_MS = 15_000L
private val TOKEN_REGEX = Regex("""/websocket\?token=([^"'\s]+)""")
private val METHODS_WITHOUT_BODY = setOf("GET", "HEAD")
private val HEADERS_BLACKLIST = hashSetOf(
// AI suggest:
"host",
"content-length",
"transfer-encoding",
"content-encoding",
"connection",
"upgrade",
"sec-websocket-key",
"sec-websocket-version",
"sec-websocket-protocol",
"Timing-Allow-Origin",
// xray
"Set-Cookie",
"Cookie",
"Origin",
"Sec-CH-UA",
"Sec-CH-UA-Mobile",
"Sec-CH-UA-Platform",
"DNT",
"User-Agent",
"Accept-Language",
"Cache-Control",
"Upgrade-Insecure-Requests",
"Sec-Fetch-Site",
"Sec-Fetch-Mode",
"Sec-Fetch-User",
"Sec-Fetch-Dest",
"Referer",
"Accept",
"Priority",
"Pragma",
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Access-Control-Expose-Headers",
"Access-Control-Max-Age",
)
}
@Volatile
private var serviceJob = SupervisorJob()
@Volatile
private var scope = CoroutineScope(serviceJob + Dispatchers.IO)
private val running = AtomicBoolean(false)
private val controlSocketState = AtomicInteger(CONTROL_SOCKET_IDLE)
private val controlSockets = ConcurrentHashMap.newKeySet<WebSocket>()
@Volatile
private var controlUrl: String? = null
private var loopJob: Job? = null
private var client: OkHttpClient? = null
@Suppress("UNUSED_PARAMETER")
override fun start(context: Context, dialerAddr: String) {
stop()
serviceJob = SupervisorJob()
scope = CoroutineScope(serviceJob + Dispatchers.IO)
if (dialerAddr.isEmpty()) return
val nativeClient = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.pingInterval(25, TimeUnit.SECONDS)
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // Disable read timeout for long-running streams
.build()
client = nativeClient
running.set(true)
loopJob = scope.launch {
val resolvedControlUrl = resolveControlWsUrl(dialerAddr, nativeClient)
if (resolvedControlUrl == null) {
debug(
"BrowserDialer: failed to resolve control url from dialer endpoint: $dialerAddr"
)
running.set(false)
return@launch
}
controlUrl = resolvedControlUrl
debug("BrowserDialer: started dialerAddr=$dialerAddr controlUrl=$resolvedControlUrl idleGate=${controlSocketState.get()}")
maintainControlSocketPool()
while (isActive && running.get()) {
maintainControlSocketPool()
delay(CONTROL_LOOP_DELAY_MS)
}
}
}
override fun stop() {
running.set(false)
loopJob?.cancel()
loopJob = null
serviceJob.cancel()
debug("BrowserDialer: stopping ${poolState()} controlUrl=$controlUrl")
controlSockets.toTypedArray().forEach { socket ->
runCatching { socket.close(1000, "stopped") }
}
controlSockets.clear()
controlSocketState.set(CONTROL_SOCKET_IDLE)
controlUrl = null
serviceJob = SupervisorJob()
scope = CoroutineScope(serviceJob + Dispatchers.IO)
val oldClient = client
client = null
oldClient?.dispatcher?.cancelAll()
oldClient?.connectionPool?.evictAll()
}
private fun maintainControlSocketPool() {
debug("BrowserDialer: maintaining single idle control socket ${poolState()}")
openControlSocket()
}
private fun openControlSocket(): Boolean {
val localClient = client ?: return false
if (!running.get()) return false
val url = controlUrl ?: return false
if (!controlSocketState.compareAndSet(
CONTROL_SOCKET_IDLE,
CONTROL_SOCKET_OPENING
)
) return false
val request = Request.Builder().url(url).build()
return runCatching {
val socket = localClient.newWebSocket(request, ControlSocketListener(url))
controlSockets.add(socket)
debug("BrowserDialer: opening control socket url=$url ${poolState()}")
true
}.getOrElse {
controlSocketState.set(CONTROL_SOCKET_IDLE)
debug("BrowserDialer: failed to open control socket", it)
false
}
}
private fun poolState(): String {
return "idleGate=${controlSocketState.get()} liveSockets=${controlSockets.size}"
}
private fun debug(message: String, throwable: Throwable? = null) {
@Suppress("KotlinConstantConditions")
if (!DEBUG_LOG) return
if (throwable == null) {
LogUtil.d(AppConfig.TAG, message)
} else {
LogUtil.d(AppConfig.TAG, message, throwable)
}
}
private fun resolveControlWsUrl(rawAddr: String, probeClient: OkHttpClient): String? {
val uri = parseDialerUri(rawAddr) ?: return null
val probeUrl = buildDialerProbeUrl(rawAddr) ?: return null
val request = Request.Builder().url(probeUrl).get().build()
val token = runCatching {
probeClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
extractControlToken(response.body.string())
}
}.getOrNull() ?: return null
val host = uri.host ?: return null
return URI(
"ws",
uri.userInfo,
host,
uri.port,
"/websocket",
"token=$token",
null
).toString()
}
private fun buildDialerProbeUrl(rawAddr: String): String? {
val normalized = rawAddr.trim()
if (normalized.isEmpty()) return null
val uri = parseDialerUri(normalized) ?: return null
val host = uri.host ?: return null
val probeScheme = when (uri.scheme?.lowercase()) {
"https", "wss" -> "https"
else -> "http"
}
return URI(probeScheme, uri.userInfo, host, uri.port, "/", null, null).toString()
}
private fun parseDialerUri(rawAddr: String): URI? {
val normalized = rawAddr.trim()
if (normalized.isEmpty()) return null
return runCatching {
if (normalized.contains("://")) URI(normalized) else URI("http://$normalized")
}.getOrNull()
}
private fun extractControlToken(html: String): String? {
val match = TOKEN_REGEX.find(html) ?: return null
return match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() }
}
private inner class ControlSocketListener(
private val controlUrl: String
) : WebSocketListener() {
private val socketId = if (DEBUG_LOG) NEXT_SOCKET_ID.incrementAndGet() else 0L
private val taskAccepted = AtomicBoolean(false)
private val closed = AtomicBoolean(false)
private val taskStartedAtMs = AtomicLong(0L)
@Volatile
private var taskKind = "none"
private var upstreamSocket: WebSocket? = null
private var upstreamCall: Call? = null
private var timeoutJob: Job? = null
private var binaryHandler: ((ByteArray) -> Unit)? = null
private var textHandler: ((String) -> Unit)? = null
override fun onOpen(webSocket: WebSocket, response: Response) {
debug("BrowserDialer: control socket opened socketId=$socketId url=$controlUrl ${poolState()}")
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (taskAccepted.compareAndSet(false, true)) {
controlSocketState.set(CONTROL_SOCKET_IDLE)
debug(
"BrowserDialer: control socket accepted task socketId=$socketId url=$controlUrl textSize=${text.length} ${poolState()}"
)
tryOpenNextControlSocket()
handleTask(webSocket, BrowserDialerTask.parse(text))
return
}
textHandler?.invoke(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
if (!taskAccepted.get()) {
failAndClose(webSocket, 1002, "task must be text json")
return
}
binaryHandler?.invoke(bytes.toByteArray())
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
cleanup(webSocket)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val status = response?.code?.toString() ?: "no-http-response"
val stateText = "${poolState()} accepted=${taskAccepted.get()} closed=${closed.get()}"
if (isExpectedControlFailure(t, status)) {
debug(
"BrowserDialer: control socket closed socketId=$socketId url=$controlUrl status=$status cause=${t.javaClass.simpleName} $stateText"
)
debug(
"BrowserDialer: control socket failure detail socketId=$socketId url=$controlUrl status=$status $stateText",
t
)
} else {
debug(
"BrowserDialer: control socket failure socketId=$socketId url=$controlUrl status=$status $stateText",
t
)
}
cleanup(webSocket)
}
private fun isExpectedControlFailure(t: Throwable, status: String): Boolean {
if (!running.get() || closed.get()) return true
if (status != "no-http-response") return false
if (t is EOFException) return true
if (t is SocketException) {
val message = t.message.orEmpty().lowercase()
if ("socket closed" in message || "software caused connection abort" in message) {
return true
}
}
val message = t.message.orEmpty().lowercase()
return "canceled" in message || "cancelled" in message
}
private fun tryOpenNextControlSocket() {
scope.launch {
if (running.get()) maintainControlSocketPool()
}
}
private fun cleanup(webSocket: WebSocket) {
if (!closed.compareAndSet(false, true)) return
val removed = controlSockets.remove(webSocket)
if (!taskAccepted.get()) {
controlSocketState.set(CONTROL_SOCKET_IDLE)
tryOpenNextControlSocket()
}
val started = taskStartedAtMs.get()
val duration =
if (started > 0L) (System.currentTimeMillis() - started).coerceAtLeast(0L) else -1L
debug(
"BrowserDialer: cleanup socketId=$socketId url=$controlUrl task=$taskKind taskAccepted=${taskAccepted.get()} removed=$removed durationMs=$duration ${poolState()}"
)
binaryHandler = null
textHandler = null
timeoutJob?.cancel()
timeoutJob = null
upstreamCall?.cancel()
upstreamCall = null
upstreamSocket?.close(1000, "control closed")
upstreamSocket = null
taskKind = "closed"
taskStartedAtMs.set(0L)
}
private fun handleTask(webSocket: WebSocket, task: BrowserDialerTask?) {
if (task == null) {
failAndClose(webSocket, 1007, "invalid task")
return
}
taskStartedAtMs.set(System.currentTimeMillis())
taskKind = when {
task.method == "WS" -> "ws"
task.method == "GET" && task.streamResponse -> "streaming_get"
!task.streamResponse -> "unary_${task.method.lowercase()}"
else -> "unsupported"
}
when {
task.method == "WS" -> handleWsTask(webSocket, task)
task.method == "GET" && task.streamResponse -> handleStreamingGetTask(
webSocket,
task
)
!task.streamResponse -> handleUnaryTask(webSocket, task)
else -> {
failAndClose(webSocket, 1003, "unsupported task")
}
}
}
private fun handleWsTask(controlSocket: WebSocket, task: BrowserDialerTask) {
val localClient = client ?: run {
failAndClose(controlSocket, 1011, "client unavailable")
return
}
debug("BrowserDialer: handling WS task socketId=$socketId url=${task.url} protocols=${task.extra.protocols.size}")
val requestBuilder = Request.Builder().url(task.url)
if (task.extra.protocols.isNotEmpty()) {
requestBuilder.header(
"Sec-WebSocket-Protocol",
task.extra.protocols.joinToString(",")
)
}
val opened = AtomicBoolean(false)
upstreamSocket =
localClient.newWebSocket(requestBuilder.build(), object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
opened.set(true)
try {
controlSocket.send("ok")
} catch (e: Exception) {
debug(
"BrowserDialer: failed to send ok for WS task",
e
)
webSocket.close(1000, "control failed")
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
controlSocket.send(text)
} catch (e: Exception) {
debug(
"BrowserDialer: control socket closed during WS message transfer socketId=$socketId",
e
)
webSocket.close(1000, "control closed")
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
try {
controlSocket.send(bytes)
} catch (e: Exception) {
debug(
"BrowserDialer: control socket closed during WS binary transfer socketId=$socketId",
e
)
webSocket.close(1000, "control closed")
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
try {
controlSocket.close(1000, "upstream closed")
} catch (_: Exception) {
debug("BrowserDialer: control socket already closed")
}
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?
) {
if (!opened.get()) {
try {
controlSocket.send("fail")
} catch (e: Exception) {
debug(
"BrowserDialer: control socket send failed socketId=$socketId",
e
)
}
}
try {
controlSocket.close(1011, "upstream failure")
} catch (_: Exception) {
debug(
"BrowserDialer: control socket already closed socketId=$socketId"
)
}
}
})
textHandler = { message ->
try {
upstreamSocket?.send(message)
} catch (e: Exception) {
debug(
"BrowserDialer: upstream socket send failed socketId=$socketId",
e
)
}
}
binaryHandler = { data ->
try {
upstreamSocket?.send(data.toByteString())
} catch (e: Exception) {
debug(
"BrowserDialer: upstream socket binary send failed socketId=$socketId",
e
)
}
}
}
private fun handleStreamingGetTask(controlSocket: WebSocket, task: BrowserDialerTask) {
val localClient = client ?: run {
failAndClose(controlSocket, 1011, "client unavailable")
return
}
debug("BrowserDialer: handling streaming GET task socketId=$socketId url=${task.url}")
val request = buildRequest(task, null)
try {
controlSocket.send("ok")
} catch (e: Exception) {
debug(
"BrowserDialer: failed to send ok for streaming GET socketId=$socketId",
e
)
return
}
scope.launch {
val call = localClient.newCall(request)
upstreamCall = call
try {
call.execute().use { response ->
val source = response.body.source()
val buffer = Buffer()
while (running.get() && !closed.get()) {
try {
val read = source.read(buffer, DEFAULT_BUFFER_SIZE.toLong())
if (read < 0) break
// Send data, catch exception if WebSocket is closed
try {
controlSocket.send(buffer.readByteString())
} catch (e: Exception) {
debug(
"BrowserDialer: WebSocket send failed during streaming, stopping stream socketId=$socketId",
e
)
break
}
} catch (_: Exception) {
// Error reading from source, stop streaming
break
}
}
}
} catch (e: Exception) {
debug(
"BrowserDialer: streaming GET failed socketId=$socketId",
e
)
try {
controlSocket.send("fail")
} catch (_: Exception) {
// WebSocket may already be closed
}
} finally {
upstreamCall = null
try {
controlSocket.close(1000, "streaming done")
} catch (_: Exception) {
debug(
"BrowserDialer: WebSocket already closed socketId=$socketId"
)
}
}
}
}
private fun handleUnaryTask(controlSocket: WebSocket, task: BrowserDialerTask) {
val localClient = client ?: run {
failAndClose(controlSocket, 1011, "client unavailable")
return
}
debug("BrowserDialer: handling unary task socketId=$socketId method=${task.method} url=${task.url}")
try {
controlSocket.send("ok")
} catch (e: Exception) {
debug(
"BrowserDialer: failed to send ok for unary task socketId=$socketId",
e
)
return
}
val done = AtomicBoolean(false)
timeoutJob = scope.launch {
delay(UNARY_BODY_WAIT_TIMEOUT_MS)
if (done.compareAndSet(false, true)) {
binaryHandler = null
textHandler = null
debug("BrowserDialer: unary task timed out waiting for payload socketId=$socketId method=${task.method} url=${task.url}")
failAndClose(controlSocket, 1000, "unary payload timeout")
}
}
val executeRequest: (ByteArray?) -> Unit = { payload ->
if (done.compareAndSet(false, true)) {
timeoutJob?.cancel()
timeoutJob = null
binaryHandler = null
textHandler = null
scope.launch {
val request = buildRequest(task, payload)
val call = localClient.newCall(request)
upstreamCall = call
try {
call.execute().use { response ->
try {
controlSocket.send(if (response.isSuccessful) "ok" else "fail")
} catch (e: Exception) {
debug(
"BrowserDialer: WebSocket send failed for unary response socketId=$socketId",
e
)
}
}
} catch (e: Exception) {
debug(
"BrowserDialer: unary request failed socketId=$socketId",
e
)
try {
controlSocket.send("fail")
} catch (_: Exception) {
// WebSocket may already be closed
}
} finally {
upstreamCall = null
try {
controlSocket.close(1000, "request done")
} catch (_: Exception) {
debug(
"BrowserDialer: WebSocket already closed socketId=$socketId"
)
}
}
}
}
}
binaryHandler = { body -> executeRequest(body.takeIf { it.isNotEmpty() }) }
textHandler = { body -> executeRequest(body.toByteArray().takeIf { it.isNotEmpty() }) }
}
private fun buildRequest(task: BrowserDialerTask, payload: ByteArray?): Request {
val requestBuilder = Request.Builder().url(task.url)
// task.extra.headers.forEach { (key, value) -> requestBuilder.header(key, value) }
// Just set no cache headers
requestBuilder.header("Cache-Control", "no-cache, no-store, must-revalidate")
task.extra.referrer?.takeIf { it.isNotBlank() }
?.let { requestBuilder.header("Referer", it) }
val taskHeaders = task.extra.headers.filterKeys { key ->
val lowerKey = key.lowercase()
!HEADERS_BLACKLIST.any { blackKey -> blackKey.equals(lowerKey, ignoreCase = true) }
}
taskHeaders.forEach { (key, value) -> requestBuilder.header(key, value) }
val method = task.method.uppercase()
val methodAllowsBody = method !in METHODS_WITHOUT_BODY
val body = when {
methodAllowsBody && payload != null && payload.isNotEmpty() -> payload.toRequestBody(null)
methodAllowsBody -> ByteArray(0).toRequestBody(null)
else -> null
}
requestBuilder.method(method, body)
return requestBuilder.build()
}
private fun failAndClose(socket: WebSocket, code: Int, reason: String) {
try {
socket.send("fail")
} catch (e: Exception) {
debug("BrowserDialer: failed to send fail message", e)
}
try {
socket.close(code, reason)
} catch (e: Exception) {
debug("BrowserDialer: failed to close socket", e)
}
}
}
private data class BrowserDialerTask(
val method: String,
val url: String,
val streamResponse: Boolean,
val extra: Extra
) {
data class Extra(
val headers: Map<String, String> = emptyMap(),
// val cookies: Map<String, String> = emptyMap(),
val protocols: List<String> = emptyList(),
val referrer: String? = null
)
companion object {
fun parse(payload: String): BrowserDialerTask? {
return runCatching {
val root = JSONObject(payload)
val method = root.optString("method")
val url = root.optString("url")
if (method.isBlank() || url.isBlank()) return null
val streamResponse = root.optBoolean("streamResponse", false)
val extraObject = root.optJSONObject("extra")
val headers = extraObject.optStringMap("headers")
// val cookies = extraObject.optStringMap("cookies")
val referrer = extraObject?.optString("referrer")?.takeIf { it.isNotBlank() }
val protocols = extraObject.optProtocols()
BrowserDialerTask(
method = method,
url = url,
streamResponse = streamResponse,
extra = Extra(
headers = headers,
// cookies = cookies,
protocols = protocols,
referrer = referrer
)
)
}.getOrNull()
}
private fun JSONObject?.optStringMap(name: String): Map<String, String> {
val child = this?.optJSONObject(name) ?: return emptyMap()
val map = LinkedHashMap<String, String>()
val iter = child.keys()
while (iter.hasNext()) {
val key = iter.next()
map[key] = child.optString(key)
}
return map
}
private fun JSONObject?.optProtocols(): List<String> {
val raw = this?.opt("protocol") ?: return emptyList()
return when (raw) {
is String -> raw.takeIf { it.isNotBlank() }?.let { listOf(it) } ?: emptyList()
is JSONArray -> buildList {
for (i in 0 until raw.length()) {
val item = raw.optString(i)
if (item.isNotBlank()) {
add(item)
}
}
}
else -> emptyList()
}
}
}
}
}
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more