Compare commits

...
164 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
302 changed files with 18270 additions and 14511 deletions

No files matched your search

+50 -17
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@v4.0.1
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') }}
@@ -78,7 +81,7 @@ 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'
@@ -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 = 726
versionName = "2.1.6"
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)
+4 -4
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,7 @@
<!--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>
@@ -41,7 +41,7 @@
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScStartActivity"
android:targetClass="com.v2ray.ang.ui.shortcut.ScStartActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
@@ -56,7 +56,7 @@
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScStopActivity"
android:targetClass="com.v2ray.ang.ui.shortcut.ScStopActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
+82 -29
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,72 +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.ServerProxyChainActivity"
android:name=".ui.server.ServerSocksActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
android:windowSoftInputMode="stateUnchanged|adjustResize" />
<activity
android:name=".ui.SettingsActivity"
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.AppPickerActivity"
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.ScStartActivity"
android:name=".ui.shortcut.ScStartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.ScStopActivity"
android:name=".ui.shortcut.ScStopActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
@@ -162,10 +192,10 @@
</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"
@@ -202,6 +232,17 @@
android:value="proxy" />
</service>
<service
android:name=".service.CoreRootService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":RunSoLibV2RayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service
android:name=".service.CoreTestService"
android:exported="false"
@@ -212,6 +253,16 @@
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"
android:exported="true"
@@ -231,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>
@@ -251,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>
@@ -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,7 +30,8 @@
"enabled": true,
"destOverride": [
"http",
"tls"
"tls",
"quic"
]
}
},
@@ -46,7 +47,8 @@
"enabled": true,
"destOverride": [
"http",
"tls"
"tls",
"quic"
]
}
}
@@ -89,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()
}
}
@@ -43,11 +43,16 @@ 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 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"
@@ -55,7 +60,6 @@ object AppConfig {
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"
@@ -70,12 +74,16 @@ 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"
@@ -105,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"
@@ -125,12 +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 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"
@@ -171,15 +185,21 @@ object AppConfig {
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"
// 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://"
@@ -188,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"
@@ -201,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
@@ -221,12 +270,18 @@ object AppConfig {
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
@@ -285,4 +340,6 @@ object AppConfig {
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()
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.service
package com.v2ray.ang.contracts
import android.content.Context
@@ -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
}
@@ -3,9 +3,11 @@ 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.ProfileItem
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
@@ -13,58 +15,132 @@ import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
/**
* Builds [com.v2ray.ang.dto.CoreConfigContext] from the selected profile.
* Keeps parsing and resolution logic out of [CoreConfigManager].
* 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 {
/** Loads profile by guid and returns a resolved runtime context. */
/**
* 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,
selectedProfile = config,
resolvedProfiles = listOf(config),
resolvedType = CoreResolvedType.CUSTOM,
)
return CoreConfigContext(context = context, guid = guid, isCustom = true)
}
// Pre-resolve custom outbound profiles from routing rulesets
val customOutbounds = resolveCustomOutbounds()
// 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
}
// Determine resolved profiles and type based on config type
val (resolvedProfiles, resolvedType) = when (config.configType) {
EConfigType.POLICYGROUP -> {
val profiles = resolvePolicyGroupProfiles(config)
Pair(profiles, CoreResolvedType.POLICYGROUP)
}
// 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 profiles = resolveProxyChainProfiles(config)
Pair(profiles, CoreResolvedType.PROXYCHAIN)
val chainProfiles = resolveProxyChainProfiles(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
else -> {
val chainProfiles = resolveProxyChainProfilesFromGroup(config)
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
}
// Create context with common fields
return CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
return CoreConfigContext.ResolvedOutbound(
tag = tag,
profile = profile,
resolvedProfiles = resolvedProfiles,
resolvedType = resolvedType,
customOutboundProfiles = customOutbounds,
)
}
/** Resolves policy-group members with the same filters as runtime build. */
/**
* 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()
@@ -92,18 +168,15 @@ object CoreConfigContextBuilder {
}
}
.filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.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 '${config.remarks}'", e)
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
return listOf(config)
}
}
/** Resolves proxy-chain members with the same filters as runtime build. */
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
if (config.proxyChainProfiles.isNullOrBlank()) {
return listOf(config)
@@ -114,26 +187,22 @@ object CoreConfigContextBuilder {
.asSequence()
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
.filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.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 '${config.remarks}'", e)
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolves chain nodes in fixed order: next -> current -> prev.
* If chain cannot be built, caller treats result as normal mode.
* 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 (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
return listOf(config)
}
if (config.subscriptionId.isEmpty()) {
return listOf(config)
}
@@ -141,56 +210,64 @@ object CoreConfigContextBuilder {
try {
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
val resolved = mutableListOf<ProfileItem>()
// Keep the same practical chain order as current runtime assembly:
// next -> current -> prev
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 profiles from group for config '${config.remarks}'", e)
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolves custom outbound profiles from routing rulesets.
* Scans rulesets for non-builtin outbound tags and looks up matching profiles by remarks.
* Returns a map of tag -> ProfileItem for independent processing.
* Collect enabled routing domain rules in original order for DNS segmentation.
*
* outbounds are normalized into three tags only: proxy / direct / block.
*/
private fun resolveCustomOutbounds(): Map<String, ProfileItem> {
val customMap = mutableMapOf<String, ProfileItem>()
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return customMap
private fun collectRoutingDomainRulesForDns(): List<CoreConfigContext.RoutingDomainRule> {
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
val result = mutableListOf<CoreConfigContext.RoutingDomainRule>()
try {
val processedTags = mutableSetOf<String>()
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 {
return@forEach
}
customMap[tag] = profile
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound for tag '$tag', skipping", e)
}
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
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound profiles", e)
}
result.add(
CoreConfigContext.RoutingDomainRule(
domain = rule.domain.orEmpty(),
outboundTag = normalizedOutboundTag
)
)
}
return customMap
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,11 +1,10 @@
package com.v2ray.ang.core
import android.text.TextUtils
import com.google.gson.JsonArray
import com.google.gson.JsonObject
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.isNotNullEmpty
@@ -65,7 +64,7 @@ object CoreOutboundBuilder {
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
outbound.mux?.concurrency = -1
}
} else {
@@ -73,41 +72,6 @@ object CoreOutboundBuilder {
outbound.mux?.concurrency = -1
}
if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
var localTunAddr = if (outbound.settings?.address == null) {
listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
} else {
outbound.settings?.address as List<*>
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) != true) {
localTunAddr = listOf(localTunAddr.first())
}
outbound.settings?.address = localTunAddr
}
if (outbound.streamSettings?.network == AppConfig.DEFAULT_NETWORK
&& outbound.streamSettings?.tcpSettings?.header?.type == AppConfig.HEADER_TYPE_HTTP
) {
val path = outbound.streamSettings?.tcpSettings?.header?.request?.path
val host = outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host
val requestString: String by lazy {
"""{"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"]}}"""
}
outbound.streamSettings?.tcpSettings?.header?.request = JsonUtil.fromJson(
requestString,
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
)
outbound.streamSettings?.tcpSettings?.header?.request?.path =
if (path.isNullOrEmpty()) {
listOf("/")
} else {
path
}
outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host = host
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
return false
@@ -119,49 +83,30 @@ object CoreOutboundBuilder {
fun createInitOutbound(configType: EConfigType): OutboundBean? {
return when (configType) {
EConfigType.VMESS,
EConfigType.VLESS ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
vnext = listOf(
OutboundBean.OutSettingsBean.VnextBean(
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
)
)
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.VLESS,
EConfigType.SHADOWSOCKS,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.TROJAN ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.TROJAN -> OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.WIREGUARD ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
secretKey = "",
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
)
EConfigType.WIREGUARD -> OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
secretKey = "",
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
)
)
EConfigType.HYSTERIA,
EConfigType.HYSTERIA2 ->
return OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
servers = null
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.HYSTERIA2 -> OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutboundBean.OutSettingsBean(),
streamSettings = OutboundBean.StreamSettingsBean()
)
else -> null
}
@@ -172,11 +117,12 @@ object CoreOutboundBuilder {
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].security = profileItem.method
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 {
@@ -193,12 +139,13 @@ object CoreOutboundBuilder {
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].encryption = profileItem.method
vnext.users[0].flow = profileItem.flow
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 {
@@ -215,11 +162,12 @@ object CoreOutboundBuilder {
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.method = profileItem.method
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 {
@@ -236,11 +184,12 @@ object CoreOutboundBuilder {
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.flow = profileItem.flow
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 {
@@ -257,14 +206,13 @@ object CoreOutboundBuilder {
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.level = AppConfig.DEFAULT_LEVEL
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
settings.user = profileItem.username.orEmpty()
settings.pass = profileItem.password.orEmpty()
}
}
@@ -274,14 +222,13 @@ object CoreOutboundBuilder {
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.level = AppConfig.DEFAULT_LEVEL
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
settings.user = profileItem.username.orEmpty()
settings.pass = profileItem.password.orEmpty()
}
}
@@ -291,9 +238,23 @@ object CoreOutboundBuilder {
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 = (profileItem.localAddress ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4).split(",")
wireguard.address = addresses
wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
@@ -303,6 +264,13 @@ object CoreOutboundBuilder {
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
}
@@ -328,6 +296,23 @@ object CoreOutboundBuilder {
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.
*
@@ -358,13 +343,9 @@ object CoreOutboundBuilder {
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
val requestObj = OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
requestObj.headers.Host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
requestObj.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
tcpSetting.header.request = requestObj
sni = requestObj.headers.Host?.getOrNull(0)
}
val requestObj = createTcpHttpRequest(host, path)
tcpSetting.header.request = requestObj
sni = requestObj.headers.Host?.getOrNull(0)
} else {
tcpSetting.header.type = "none"
sni = host
@@ -377,37 +358,40 @@ object CoreOutboundBuilder {
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
profileItem.kcpTti?.let { kcpSetting.tti = it }
streamSettings.kcpSettings = kcpSetting
val udpMaskList = mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
val udpMaskList =
mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
if (!headerType.isNullOrEmpty() && headerType != "none") {
val kcpHeaderType = when {
headerType == "wechat-video" -> "header-wechat"
else -> "header-$headerType"
headerType == "wechat-video" -> "wechat"
else -> headerType
}
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = kcpHeaderType,
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
type = "mkcp-legacy",
settings =
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
domain = host
header = kcpHeaderType,
value = if (headerType == "dns" && !host.isNullOrEmpty()) {
host
} else {
null
}
)
} else {
null
}
)
)
}
if (seed.isNullOrEmpty()) {
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-original"
type = "mkcp-legacy"
)
)
} else {
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-aes128gcm",
type = "mkcp-legacy",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = seed
value = seed
)
)
)
@@ -419,7 +403,7 @@ object CoreOutboundBuilder {
NetworkType.WS.type -> {
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
wssetting.headers.Host = host.orEmpty()
wssetting.host = host.orEmpty()
sni = host
wssetting.path = path ?: "/"
streamSettings.wsSettings = wssetting
@@ -554,7 +538,7 @@ object CoreOutboundBuilder {
*/
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
val streamSecurity = profileItem.security.orEmpty()
val allowInsecure = profileItem.insecure == true
val allowInsecure = profileItem.insecure == true && profileItem.pinnedCA256.isNullOrEmpty()
val sni = if (profileItem.sni.isNullOrEmpty()) {
when {
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
@@ -573,6 +557,7 @@ object CoreOutboundBuilder {
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(),
@@ -620,10 +605,6 @@ object CoreOutboundBuilder {
&& packets == "tlshello"
) {
packets = "1-3"
} else if (streamSettings.security == AppConfig.TLS
&& packets != "tlshello"
) {
packets = "tlshello"
}
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
@@ -633,7 +614,9 @@ object CoreOutboundBuilder {
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
?: "50-100",
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
?: "10-20"
?: "10-20",
maxSplit = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_MAXSPLIT)
?: "10"
)
)
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
@@ -694,4 +677,16 @@ object CoreOutboundBuilder {
}
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")
}
}
}
}
@@ -12,25 +12,26 @@ 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.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
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.service.CoreProxyOnlyService
import com.v2ray.ang.service.CoreVpnService
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.service.DialerNativeService
import com.v2ray.ang.service.DialerWebviewService
import com.v2ray.ang.service.IDialerService
import com.v2ray.ang.service.NetworkMonitor
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.jvm.Volatile
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.ProcessFinder
@@ -44,6 +45,13 @@ object CoreServiceManager {
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) {
@@ -56,55 +64,6 @@ object CoreServiceManager {
}
}
/**
* Starts the V2Ray service from a toggle action.
* @param context The context from which the service is started.
* @return True if the service was started successfully, false otherwise.
*/
fun startVServiceFromToggle(context: Context): Boolean {
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
context.toast(R.string.app_tile_first_use)
return false
}
try {
startContextService(context)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
context.toast(e.message ?: e.javaClass.simpleName)
return false
}
return true
}
/**
* Starts the V2Ray service.
* @param context The context from which the service is started.
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) {
MmkvManager.setSelectServer(guid)
}
try {
startContextService(context)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
context.toast(e.message ?: e.javaClass.simpleName)
}
}
/**
* Stops the V2Ray service.
* @param context The context from which the service is stopped.
*/
fun stopVService(context: Context) {
//context.toast(R.string.toast_services_stop)
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
}
/**
* Checks if the V2Ray service is running.
* @return True if the service is running, false otherwise.
@@ -117,74 +76,13 @@ object CoreServiceManager {
*/
fun getRunningServerName() = currentConfig?.remarks.orEmpty()
/**
* Starts the context service for V2Ray.
* Chooses between VPN service or Proxy-only service based on user settings.
* @param context The context from which the service is started.
* @throws IllegalStateException if the core is already running, no server is selected,
* server config cannot be decoded, or server configuration is invalid.
* @throws Exception if the foreground service fails to start.
*/
@Throws(Exception::class)
private fun startContextService(context: Context) {
if (coreController.isRunning) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return
}
val guid = MmkvManager.getSelectServer()
?: run {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
error(context.getString(R.string.app_tile_first_use))
}
val config = MmkvManager.decodeServerConfig(guid)
?: run {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
error(context.getString(R.string.toast_config_file_invalid))
}
if (config.configType != EConfigType.CUSTOM
&& config.configType != EConfigType.POLICYGROUP
&& config.configType != EConfigType.PROXYCHAIN
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
error(context.getString(R.string.toast_config_file_invalid))
}
// refresh socks port when enabled dynamic socks port
SettingsManager.refreshRuntimeSocksPort()
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
context.toast(R.string.toast_warning_pref_proxysharing_short)
} else {
context.toast(R.string.toast_services_start)
}
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, CoreVpnService::class.java)
} else {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
}
ContextCompat.startForegroundService(context, intent)
}
/**
* Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
* `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
* Starts the V2Ray core service.
*/
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
if (coreController.isRunning) {
if (isRunning()) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false
}
@@ -201,7 +99,7 @@ object CoreServiceManager {
} catch (e: Exception) {
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
LogUtil.e(AppConfig.TAG, "StartCore-Manager: $message", e)
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
NotificationManager.cancelNotification()
return false
}
@@ -209,6 +107,19 @@ object CoreServiceManager {
@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")
@@ -219,28 +130,25 @@ object CoreServiceManager {
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
}
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
currentConfig = config
var tunFd = vpnInterface?.fd ?: 0
val dialerAddr = if (currentConfig?.browserDialerMode.isNullOrEmpty()) {
""
} else {
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)
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
if (dialerAddr.isNotNullEmpty()) {
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
}
coreController.startLoop(result.content, tunFd)
if (!coreController.isRunning) {
if (!isRunning()) {
error("Core failed to start")
}
@@ -248,16 +156,24 @@ object CoreServiceManager {
browserDialer!!.stop()
browserDialer = null
}
if (config.browserDialerMode == "OkHttp") {
browserDialer = DialerNativeService()
browserDialer!!.start(service, dialerAddr)
} else if (config.browserDialerMode == "WebView") {
browserDialer = DialerWebviewService()
browserDialer!!.start(service, dialerAddr)
when (dialerMode) {
BrowserDialerMode.OKHTTP -> {
browserDialer = DialerNativeService()
browserDialer!!.start(service, dialerAddr)
}
BrowserDialerMode.WEBVIEW -> {
browserDialer = DialerWebviewService()
browserDialer!!.start(service, dialerAddr)
}
else -> {}
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.startSpeedNotification(currentConfig)
if (!isReload) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
}
NotificationManager.startSpeedNotification()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
}
@@ -269,7 +185,11 @@ object CoreServiceManager {
fun stopCoreLoop(): Boolean {
val service = getService() ?: return false
if (coreController.isRunning) {
networkMonitor?.unregister()
networkMonitor = null
currentVpnInterface = null
if (isRunning()) {
CoroutineScope(Dispatchers.IO).launch {
try {
coreController.stopLoop()
@@ -286,7 +206,7 @@ object CoreServiceManager {
browserDialer = null
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
NotificationManager.cancelNotification()
try {
@@ -299,13 +219,104 @@ object CoreServiceManager {
}
/**
* 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.
* 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.
*/
fun queryStats(tag: String, link: String): Long {
return coreController.queryStats(tag, link)
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
}
/**
@@ -314,7 +325,7 @@ object CoreServiceManager {
* Also fetches remote IP information if the delay test was successful.
*/
private fun measureV2rayDelay() {
if (coreController.isRunning == false) {
if (!isRunning()) {
return
}
@@ -343,12 +354,12 @@ object CoreServiceManager {
} else {
service.getString(R.string.connection_test_error, errorStr)
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
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 ->
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
}
}
}
@@ -453,10 +464,10 @@ object CoreServiceManager {
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, "")
if (isRunning()) {
MessageHelper.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
} else {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
MessageHelper.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
}
}
@@ -477,7 +488,7 @@ object CoreServiceManager {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService()
Thread.sleep(500L)
startVService(serviceControl.getService())
LauncherManager.startService(serviceControl.getService())
}
AppConfig.MSG_MEASURE_DELAY -> {
@@ -488,12 +499,12 @@ object CoreServiceManager {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification(currentConfig)
NotificationManager.stopSpeedNotification()
}
Intent.ACTION_SCREEN_ON -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification(currentConfig)
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 = "",
)
@@ -1,16 +1,25 @@
package com.v2ray.ang.dto
import android.content.Context
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.CoreResolvedType
/**
* Runtime context produced by the builder and consumed by CoreConfigManager.
*/
data class CoreConfigContext(
val context: Context,
val guid: String,
val selectedProfile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
val customOutboundProfiles: Map<String, ProfileItem> = emptyMap(),
)
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,138 +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 kcpMtu: Int? = null,
var kcpTti: Int? = null,
var quicSecurity: String? = null,
var quicKey: String? = null,
var mode: String? = null,
var serviceName: String? = null,
var authority: String? = null,
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null,
var sni: String? = null,
var alpn: String? = null,
var fingerPrint: String? = null,
var insecure: Boolean? = null,
var echConfigList: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null,
var shortId: String? = null,
var spiderX: String? = null,
var mldsa65Verify: String? = null,
var secretKey: String? = null,
var preSharedKey: String? = null,
var localAddress: String? = null,
var reserved: String? = null,
var mtu: Int? = null,
var obfsPassword: String? = null,
var portHopping: String? = null,
var portHoppingInterval: String? = null,
var pinSHA256: String? = null,
var bandwidthDown: String? = null,
var bandwidthUp: String? = null,
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
var proxyChainProfiles: String? = null,
var browserDialerMode: String? = null,
) {
companion object {
fun create(configType: EConfigType): ProfileItem {
return ProfileItem(configType = configType)
}
}
fun 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.kcpMtu == obj.kcpMtu
&& this.kcpTti == obj.kcpTti
&& this.quicSecurity == obj.quicSecurity
&& this.quicKey == obj.quicKey
&& this.mode == obj.mode
&& this.serviceName == obj.serviceName
&& this.authority == obj.authority
&& this.xhttpMode == obj.xhttpMode
&& this.security == obj.security
&& this.sni == obj.sni
&& this.alpn == obj.alpn
&& this.fingerPrint == obj.fingerPrint
&& this.publicKey == obj.publicKey
&& this.shortId == obj.shortId
&& this.secretKey == obj.secretKey
&& this.localAddress == obj.localAddress
&& this.reserved == obj.reserved
&& this.mtu == obj.mtu
&& this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinnedCA256 == obj.pinnedCA256
&& this.proxyChainProfiles == obj.proxyChainProfiles
)
}
}
@@ -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
)
@@ -68,71 +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 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 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,
@@ -141,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,
@@ -181,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
)
}
}
@@ -199,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,
@@ -256,9 +222,9 @@ data class V2rayConfig(
val disableSystemRoot: Boolean? = null,
val enableSessionResumption: Boolean? = null,
var echConfigList: 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,
@@ -298,12 +264,13 @@ data class V2rayConfig(
) {
data class MaskSettingsBean(
val password: String? = null,
val domain: 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,
val maxSplit: String? = null,
// noise
val reset: Int? = null,
val noise: List<NoiseMaskBean>? = null
@@ -341,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?.firstOrNull()?.address ?: settings?.address as? String
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
) {
return settings?.servers?.firstOrNull()?.address
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.address as? String
return 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?.firstOrNull()?.port ?: settings?.port
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
) {
return settings?.servers?.firstOrNull()?.port
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toInt()
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.port
return if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toIntOrNull()
} else {
settings?.port
}
return null
}
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
@@ -496,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,6 +1,7 @@
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,
@@ -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 = "",
@@ -13,5 +13,6 @@ data class SubscriptionItem(
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 }
}
}
@@ -5,5 +5,4 @@ enum class CoreResolvedType {
NORMAL,
POLICYGROUP,
PROXYCHAIN,
CUSTOM,
}
@@ -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
@@ -1,7 +1,7 @@
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.util.HttpUtil
@@ -50,9 +50,8 @@ 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"]
@@ -79,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"]
@@ -105,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 }
@@ -1,13 +1,12 @@
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.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -19,7 +18,6 @@ object Hysteria2Fmt : 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.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -33,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"]
}
@@ -69,39 +64,6 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHopping.isNotNullEmpty()) {
dicQuery["mport"] = config.portHopping.orEmpty()
}
if (config.portHoppingInterval.isNotNullEmpty()) {
val rawInterval = config.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
null
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
null
} else {
rawInterval
}
} else {
val parts = rawInterval.split('-')
if (parts.size == 2) {
val start = parts[0].trim().toIntOrNull()
val end = parts[1].trim().toIntOrNull()
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
(minStart + minEnd) / 2
} else {
null
}
} else {
null
}
}
}
if (interval != null) {
dicQuery["mportHopInt"] = interval.toString()
}
}
if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
}
@@ -1,7 +1,7 @@
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.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
@@ -1,6 +1,6 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
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
@@ -26,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()
@@ -1,11 +1,10 @@
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.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -17,7 +16,6 @@ object TrojanFmt : 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.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -29,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
}
@@ -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,10 +1,8 @@
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.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -17,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))
@@ -30,7 +27,7 @@ object VlessFmt : FmtBase() {
config.password = uri.userInfo
config.method = queryParam["encryption"] ?: "none"
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
return config
}
@@ -2,13 +2,12 @@ package com.v2ray.ang.fmt
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
@@ -26,7 +25,6 @@ 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, "")
@@ -87,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
}
@@ -142,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)
@@ -154,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))
@@ -167,7 +169,7 @@ object VmessFmt : FmtBase() {
config.password = uri.userInfo
config.method = AppConfig.DEFAULT_SECURITY
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
return config
}
@@ -1,7 +1,7 @@
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.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
@@ -4,13 +4,13 @@ import android.content.Context
import android.graphics.Bitmap
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.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,6 +18,7 @@ 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
@@ -30,6 +31,22 @@ 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.
@@ -219,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)
@@ -273,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()
@@ -282,10 +291,6 @@ object AngConfigManager {
if (!serverList.contains(key)) {
serverList.add(0, key)
if (needSetSelected) {
MmkvManager.setSelectServer(key)
needSetSelected = false
}
}
keyToProfile[key] = config
}
@@ -308,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()) {
@@ -335,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 }
}
/**
@@ -371,18 +400,25 @@ 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) {
@@ -442,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) {
@@ -474,6 +496,12 @@ 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) {
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
@@ -530,19 +558,36 @@ object AngConfigManager {
}
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, proxyUsername, proxyPassword)
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent,
requestHeaders = requestHeaders,
timeout = 15000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
)
} catch (e: Exception) {
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent,
requestHeaders = requestHeaders
)
)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
@@ -571,6 +616,41 @@ object AngConfigManager {
}
}
/**
* 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.
*
@@ -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
}
@@ -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()
}
/**
@@ -714,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
@@ -14,9 +14,9 @@ import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.ProfileItem
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
@@ -43,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 || 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) {
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
delay(QUERY_INTERVAL_MS)
continue
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
val up = CoreServiceManager.queryStats(it, AppConfig.UPLINK)
val down = CoreServiceManager.queryStats(it, AppConfig.DOWNLINK)
if (up + down > 0) {
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
proxyTotal += up + down
}
}
val directUplink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = CoreServiceManager.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)
}
}
@@ -131,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)
@@ -143,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
)
@@ -153,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.
*/
@@ -168,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)
}
}
@@ -186,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
@@ -247,6 +216,69 @@ 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.
@@ -254,4 +286,4 @@ object NotificationManager {
private fun getService(): Service? {
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)
}
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.AssetManager
import android.os.Build
import android.text.TextUtils
import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
@@ -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
@@ -31,7 +31,6 @@ 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
@@ -73,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()
}
/**
@@ -97,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
}
@@ -191,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
}
@@ -205,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 = decodeSubsList()
if (subsList.isEmpty()) return
Collections.swap(subsList, fromPosition, toPosition)
MmkvManager.encodeSubsList(subsList)
}
/**
* Get server via remarks.
* @param remarks The remarks of the server.
@@ -246,6 +219,20 @@ 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,
@@ -425,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
@@ -473,6 +449,13 @@ 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.
*/
@@ -513,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) {
@@ -571,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
}
@@ -608,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)
}
}
}
@@ -2,11 +2,10 @@ package com.v2ray.ang.handler
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
@@ -14,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.
*
@@ -44,42 +20,34 @@ 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) {
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
} catch (e: IOException) {
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: ${e.message}")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
} finally {
socket?.let { s ->
try {
if (!s.isClosed) {
s.close()
}
} catch (closeEx: IOException) {
}
}
}
return -1
}
/**
* Closes all TCP sockets that are currently being tested.
*/
fun closeAllTcpSockets() {
synchronized(this) {
tcpTestingSockets.forEach {
it?.close()
}
tcpTestingSockets.clear()
}
}
fun getRemoteIPInfo(): String? {
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
@@ -88,8 +56,16 @@ object SpeedtestManager {
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
if (httpPort == 0) return null
val content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: return null
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: 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,
@@ -12,11 +12,9 @@ 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.SubscriptionCache
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.NotificationHelper
import java.util.concurrent.TimeUnit
object SubscriptionUpdater {
@@ -44,14 +42,15 @@ object SubscriptionUpdater {
ExistingPeriodicWorkPolicy.KEEP
}
MmkvManager.decodeSubscriptions().forEach { sub ->
scheduleOne(
context = context,
subId = sub.guid,
shouldRun = sub.subscription.autoUpdate,
existingWorkPolicy = existingWorkPolicy
)
}
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"
@@ -63,11 +62,9 @@ object SubscriptionUpdater {
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
*/
fun syncOne(context: Context = AngApplication.application, subId: String) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
scheduleOne(
context = context,
subId = subId,
shouldRun = subItem.autoUpdate,
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
)
}
@@ -81,6 +78,17 @@ object SubscriptionUpdater {
.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
// -------------------------------------------------------------------------
@@ -90,17 +98,20 @@ object SubscriptionUpdater {
private fun scheduleOne(
context: Context,
subId: String,
shouldRun: Boolean,
existingWorkPolicy: ExistingPeriodicWorkPolicy
) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
val rw = RemoteWorkManager.getInstance(context)
if (!shouldRun) {
rw.cancelUniqueWork(taskName(subId))
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for $subId")
if (!subItem.autoUpdate) {
cancelOne(context, subId)
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for ${subItem.remarks}")
return
}
val subItem = MmkvManager.decodeSubscription(subId) ?: 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,
@@ -111,12 +122,17 @@ object SubscriptionUpdater {
val lastUpdated = subItem.lastUpdated
val intervalMillis = intervalMinutes * 60 * 1000L
val now = System.currentTimeMillis()
val initialDelayMillis = if (lastUpdated <= 0L) {
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()
@@ -136,7 +152,7 @@ object SubscriptionUpdater {
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
"SubscriptionUpdater: scheduled [${subItem.remarks}] interval=${intervalMinutes}min " +
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
)
}
@@ -153,41 +169,21 @@ object SubscriptionUpdater {
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
val subId = inputData.getString(KEY_SUB_ID)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update starting: $subId")
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update starting via Service: $subId")
if (subId.isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
return Result.success()
}
val subItem = MmkvManager.decodeSubscription(subId)
if (subItem == null) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
return Result.success()
}
updateLastUpdatedAndReschedule(applicationContext, subId)
if (!subItem.autoUpdate) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: auto-update disabled for $subId, skip")
return Result.success()
}
val sub = SubscriptionCache(subId, subItem)
// Notify about update start
NotificationHelper.notify(
NotificationChannelType.SUBSCRIPTION_UPDATE,
MessageHelper.sendMsg2SubscriptionService(
applicationContext,
applicationContext.getString(R.string.title_pref_auto_update_subscription),
"Updating ${sub.subscription.remarks}"
SubscriptionUpdateMessage(AppConfig.MSG_SUB_UPDATE_START, true, listOf(subId))
)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
AngConfigManager.updateConfigViaSub(sub)
// Clear notification
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
return Result.success()
}
}
}
}
@@ -5,6 +5,7 @@ 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
@@ -23,19 +24,32 @@ object UpdateCheckerManager {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var response = HttpUtil.getUrlContent(url, 5000)
var response = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000
)
)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword)
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)
@@ -1,7 +1,7 @@
package com.v2ray.ang.handler
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
@@ -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,11 +1,12 @@
package com.v2ray.ang.helper
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
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
@@ -15,14 +16,14 @@ 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)
@@ -85,4 +86,4 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
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()
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.util
package com.v2ray.ang.helper
import android.content.ComponentName
import android.content.Context
@@ -6,11 +6,14 @@ 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 MessageUtil {
object MessageHelper {
/**
@@ -69,6 +72,39 @@ object MessageUtil {
}
}
/**
* 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.
*
@@ -89,4 +125,4 @@ object MessageUtil {
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
}
}
}
}
@@ -1,89 +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
import com.v2ray.ang.util.LogUtil
/**
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
* This ensures that all Preference UI operations read/write directly from/to MMKV,
* avoiding inconsistencies between SharedPreferences and MMKV.
*/
class MmkvPreferenceDataStore : PreferenceDataStore() {
override fun putString(key: String, value: String?) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getString(key: String, defaultValue: String?): String? {
return MmkvManager.decodeSettingsString(key, defaultValue)
}
override fun putInt(key: String, value: Int) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getInt(key: String, defaultValue: Int): Int {
return MmkvManager.decodeSettingsInt(key, defaultValue)
}
override fun putLong(key: String, value: Long) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getLong(key: String, defaultValue: Long): Long {
return MmkvManager.decodeSettingsLong(key, defaultValue)
}
override fun putFloat(key: String, value: Float) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getFloat(key: String, defaultValue: Float): Float {
return MmkvManager.decodeSettingsFloat(key, defaultValue)
}
override fun putBoolean(key: String, value: Boolean) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
return MmkvManager.decodeSettingsBool(key, defaultValue)
}
override fun putStringSet(key: String, values: MutableSet<String>?) {
if (values == null) {
MmkvManager.encodeSettings(key, null as String?)
} else {
MmkvManager.encodeSettings(key, values)
}
notifySettingChanged(key)
}
override fun getStringSet(key: String, defaultValues: MutableSet<String>?): MutableSet<String>? {
return MmkvManager.decodeSettingsStringSet(key) ?: defaultValues
}
// Internal helper: notify other modules about setting changes
private fun notifySettingChanged(key: String) {
if (key == AppConfig.PREF_LOGLEVEL) {
LogUtil.refreshLogLevel()
}
// Call SettingsManager.setNightMode if UI mode changed
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
SettingsManager.setNightMode()
}
// Notify listeners that require service restart or reinit
SettingsChangeManager.makeRestartService()
SettingsChangeManager.makeSetupGroupTab()
}
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.util
package com.v2ray.ang.helper
import android.app.Notification
import android.app.NotificationChannel
@@ -55,13 +55,14 @@ object NotificationHelper {
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, "", content)
buildNotificationBuilder(channelType, context, title, content)
}
// Update only the content text (fast operation)
@@ -158,5 +159,4 @@ object NotificationHelper {
.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,8 +3,9 @@ package com.v2ray.ang.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.UserManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.core.LauncherManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.util.LogUtil
@@ -12,18 +13,36 @@ 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?) {
LogUtil.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) {
LogUtil.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()) {
@@ -37,7 +56,7 @@ class BootReceiver : BroadcastReceiver() {
}
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
CoreServiceManager.startVService(context)
LauncherManager.startService(context)
SubscriptionUpdater.sync(context)
}
}
@@ -5,7 +5,7 @@ import android.content.Context
import android.content.Intent
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.core.LauncherManager
import com.v2ray.ang.util.LogUtil
class TaskerReceiver : BroadcastReceiver() {
@@ -28,12 +28,12 @@ class TaskerReceiver : BroadcastReceiver() {
return
} else if (switch) {
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
CoreServiceManager.startVServiceFromToggle(context)
LauncherManager.startServiceFromToggle(context)
} else {
CoreServiceManager.startVService(context, guid)
LauncherManager.startService(context, guid)
}
} else {
CoreServiceManager.stopVService(context)
LauncherManager.stopService(context)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
@@ -10,6 +10,7 @@ import android.widget.RemoteViews
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.core.LauncherManager
class WidgetProvider : AppWidgetProvider() {
/**
@@ -68,9 +69,9 @@ class WidgetProvider : AppWidgetProvider() {
super.onReceive(context, intent)
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(context)
LauncherManager.stopService(context)
} else {
CoreServiceManager.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)
}
}
}
@@ -7,6 +7,7 @@ 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.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
@@ -30,8 +31,15 @@ class CoreProxyOnlyService : Service(), ServiceControl {
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
NotificationManager.ensureForeground()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
CoreServiceManager.startCoreLoop(null)
if (!CoreServiceManager.startCoreLoop(null)) {
LogUtil.e(AppConfig.TAG, "StartCore-Proxy: Failed to start core loop")
stopSelf()
return START_NOT_STICKY
}
return START_STICKY
}
@@ -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)
}
}
@@ -11,9 +11,9 @@ 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 com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.NotificationHelper
import java.util.Collections
class CoreTestService : Service() {
@@ -59,6 +59,12 @@ class CoreTestService : Service() {
* @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)
@@ -78,13 +84,6 @@ class CoreTestService : Service() {
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
NotificationHelper.startForeground(
this,
NotificationChannelType.CORE_TEST,
getString(R.string.app_name),
getString(R.string.title_real_ping_all_server)
)
val guidsList = when {
message.serverGuids.isNotEmpty() -> message.serverGuids
message.subscriptionId.isNotEmpty() -> MmkvManager.decodeServerList(message.subscriptionId)
@@ -96,6 +95,7 @@ class CoreTestService : Service() {
worker = RealPingWorkerService(
context = this,
guids = guidsList,
onlyTcp = message.onlyTcp,
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
)
activeWorkers.add(worker)
@@ -112,18 +112,19 @@ class CoreTestService : Service() {
NotificationHelper.updateNotification(
channelType = NotificationChannelType.CORE_TEST,
context = this,
title = getString(R.string.app_name),
content = getString(R.string.connection_runing_task_left, event.text)
)
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
}
is RealPingEvent.Finish -> {
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
onWorkerDone()
if (activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this)
@@ -141,4 +142,4 @@ class CoreTestService : Service() {
NotificationHelper.stopForeground(this)
stopSelf()
}
}
}
@@ -5,16 +5,12 @@ 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 androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig
@@ -24,53 +20,19 @@ 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.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 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()
@@ -108,16 +70,31 @@ class CoreVpnService : VpnService(), ServiceControl {
}
}
unlockStart()
NotificationManager.cancelNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
NotificationManager.showNotification(null)
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 {
@@ -134,6 +111,9 @@ class CoreVpnService : VpnService(), ServiceControl {
stopAllService()
return
}
// Start LAN sharing if enabled in settings
RootLanSharing.startClientSharing(this)
}
override fun stopService() {
@@ -144,6 +124,10 @@ class CoreVpnService : 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())
@@ -155,21 +139,20 @@ class CoreVpnService : 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) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
stopSelf()
return
return false
}
if (configureVpnService() != true) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
stopSelf()
return
return false
}
runTun2socks()
return true
}
/**
@@ -263,15 +246,6 @@ class CoreVpnService : 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) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
}
}
// Android Q (API 29) and above: Configure metering and HTTP proxy
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false)
@@ -350,18 +324,14 @@ class CoreVpnService : 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) {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
}
}
tun2SocksService?.stopTun2Socks()
tun2SocksService = null
RootLanSharing.stopClientSharing(this)
CoreServiceManager.stopCoreLoop()
if (isForced) {
@@ -391,5 +361,14 @@ class CoreVpnService : VpnService(), ServiceControl {
}
}
}
}
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")
}
}
@@ -2,6 +2,7 @@ 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
@@ -61,6 +62,45 @@ class DialerNativeService : IDialerService {
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
@@ -630,6 +670,11 @@ class DialerNativeService : IDialerService {
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
@@ -663,7 +708,7 @@ class DialerNativeService : IDialerService {
val extra: Extra
) {
data class Extra(
// val headers: Map<String, String> = emptyMap(),
val headers: Map<String, String> = emptyMap(),
// val cookies: Map<String, String> = emptyMap(),
val protocols: List<String> = emptyList(),
val referrer: String? = null
@@ -679,7 +724,7 @@ class DialerNativeService : IDialerService {
val streamResponse = root.optBoolean("streamResponse", false)
val extraObject = root.optJSONObject("extra")
// val headers = extraObject.optStringMap("headers")
val headers = extraObject.optStringMap("headers")
// val cookies = extraObject.optStringMap("cookies")
val referrer = extraObject?.optString("referrer")?.takeIf { it.isNotBlank() }
val protocols = extraObject.optProtocols()
@@ -689,7 +734,7 @@ class DialerNativeService : IDialerService {
url = url,
streamResponse = streamResponse,
extra = Extra(
// headers = headers,
headers = headers,
// cookies = cookies,
protocols = protocols,
referrer = referrer
@@ -698,16 +743,16 @@ class DialerNativeService : IDialerService {
}.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?.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()
@@ -7,6 +7,7 @@ import android.os.Looper
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import com.v2ray.ang.contracts.IDialerService
class DialerWebviewService : IDialerService {
private var webView: WebView? = null
@@ -0,0 +1,119 @@
package com.v2ray.ang.service
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Watches the network that carries the tunnel and reports topology changes.
*
* Cellular -> Wi-Fi is a make-before-break handover: the new network is announced while the old one
* is still connected, so the socket to the server is never reset and the core keeps using a dead
* connection. Deciding that a handover happened is what this class is for, acting on it is not.
*
* Only used from Android P and above, see CoreServiceManager.startNetworkMonitor().
* [onHandover] is invoked on a background thread after the debounce window and may block.
*/
class NetworkMonitor(
private val connectivity: ConnectivityManager,
private val onUnderlyingNetworksChanged: (Array<Network>?) -> Unit,
private val onHandover: () -> Unit,
) {
private companion object {
const val HANDOVER_DEBOUNCE_MS = 1000L
}
private var upstream: Network? = null
private var handoverJob: Job? = null
private var registered = false
/**
* 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
*/
private val request by lazy {
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
.build()
}
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val previous = upstream
upstream = network
onUnderlyingNetworksChanged(arrayOf(network))
if (previous != null && previous != network) {
scheduleHandover(network)
}
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
// it's a good idea to refresh capabilities
onUnderlyingNetworksChanged(arrayOf(network))
}
override fun onLost(network: Network) {
onUnderlyingNetworksChanged(null)
}
}
/**
* Starts watching. Safe to call more than once, only the first call registers.
*/
fun register() {
if (registered) return
try {
connectivity.requestNetwork(request, callback)
registered = true
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "NetworkMonitor: Failed to request network", e)
}
}
/**
* Stops watching and drops the tracked state. Safe to call more than once.
*/
fun unregister() {
handoverJob?.cancel()
handoverJob = null
upstream = null
if (!registered) return
registered = false
try {
connectivity.unregisterNetworkCallback(callback)
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "NetworkMonitor: Failed to unregister callback", e)
}
}
private fun scheduleHandover(network: Network) {
LogUtil.i(AppConfig.TAG, "NetworkMonitor: Upstream is now $network")
handoverJob?.cancel()
handoverJob = CoroutineScope(Dispatchers.IO).launch {
try {
delay(HANDOVER_DEBOUNCE_MS)
onHandover()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "NetworkMonitor: Failed to handle upstream change", e)
}
}
}
}
@@ -11,8 +11,9 @@ import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.core.LauncherManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
@@ -50,7 +51,7 @@ class QSTileService : TileService() {
mMsgReceive = ReceiveMessageHandler(this)
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY)
ContextCompat.registerReceiver(applicationContext, mMsgReceive, mFilter, Utils.receiverFlags())
MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
MessageHelper.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
}
/**
@@ -75,11 +76,11 @@ class QSTileService : TileService() {
super.onClick()
when (qsTile.state) {
Tile.STATE_INACTIVE -> {
CoreServiceManager.startVServiceFromToggle(this)
LauncherManager.startServiceFromToggle(this)
}
Tile.STATE_ACTIVE -> {
CoreServiceManager.stopVService(this)
LauncherManager.stopService(this)
}
}
}
@@ -4,7 +4,12 @@ import android.content.Context
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.dto.RealPingEvent
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.handler.SpeedtestManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
@@ -22,11 +27,12 @@ import java.util.concurrent.atomic.AtomicInteger
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
private val onlyTcp: Boolean = false,
private val onEvent: (RealPingEvent) -> Unit = {}
) {
private val job = SupervisorJob()
private val concurrency = SettingsManager.getRealPingConcurrency()
private val dispatcher = Executors.newFixedThreadPool(concurrency).asCoroutineDispatcher()
private val dispatcher = Executors.newFixedThreadPool(if (onlyTcp) concurrency * 2 else concurrency).asCoroutineDispatcher()
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
private val runningCount = AtomicInteger(0)
@@ -38,7 +44,7 @@ class RealPingWorkerService(
scope.launch {
runningCount.incrementAndGet()
try {
val result = startRealPing(guid)
val result = if (onlyTcp) startTcping(guid) else startRealPing(guid)
onEvent(RealPingEvent.Result(guid, result))
} catch (_: Throwable) {
// ignore
@@ -76,10 +82,48 @@ class RealPingWorkerService(
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
if (!config.configType.isComplexType()
&& config.configType != EConfigType.HYSTERIA2
&& config.configType != EConfigType.WIREGUARD
&& config.alpn?.startsWith("h3") != true
&& config.server.isNotNullEmpty()
&& config.serverPort?.toIntOrNull() != null
) {
val url = config.server.orEmpty()
val port = config.serverPort.orEmpty().toInt()
val tcpTime = SpeedtestManager.socketConnectTime(url, port, 1000)
if (tcpTime <= -1L) {
return retFailure
}
}
val configResult = CoreConfigManager.getV2rayConfig4Speedtest(context, guid)
if (!configResult.status) {
return retFailure
}
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
private fun startTcping(guid: String): Long {
val retFailure = -1L
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
if (!config.configType.isComplexType()
&& config.configType != EConfigType.HYSTERIA2
&& config.configType != EConfigType.WIREGUARD
&& config.alpn?.startsWith("h3") != true
&& config.server.isNotNullEmpty()
&& config.serverPort?.toIntOrNull() != null
) {
val url = config.server.orEmpty()
val port = config.serverPort.orEmpty().toInt()
val tcpTime = SpeedtestManager.socketConnectTime(url, port, 1000)
return tcpTime
}
return retFailure
}
}
@@ -0,0 +1,214 @@
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.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.Collections
import java.util.concurrent.atomic.AtomicInteger
class SubscriptionUpdateService : Service() {
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val runningTasks = AtomicInteger(0)
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val updateSemaphore = Semaphore(2)
override fun onCreate() {
super.onCreate()
CoreNativeManager.initCoreEnv(this)
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService is being destroyed")
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
serviceJob.cancel()
NotificationHelper.stopForeground(this)
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, this)
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
NotificationHelper.startForeground(
this,
NotificationChannelType.SUBSCRIPTION_UPDATE,
getString(R.string.title_pref_auto_update_subscription),
getString(R.string.app_name)
)
val message = intent?.serializable<SubscriptionUpdateMessage>("content")
if (message == null) {
stopSelf(startId)
return START_NOT_STICKY
}
when (message.key) {
AppConfig.MSG_SUB_UPDATE_START -> handleUpdateStart(message)
AppConfig.MSG_SUB_UPDATE_CANCEL -> {
NotificationHelper.stopForeground(this)
stopSelf(startId)
}
else -> {
NotificationHelper.stopForeground(this)
stopSelf(startId)
}
}
return START_NOT_STICKY
}
private fun handleUpdateStart(message: SubscriptionUpdateMessage) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService starting update task for ${message.subIds.size} subscriptions")
runningTasks.incrementAndGet()
serviceScope.launch {
updateSemaphore.withPermit {
try {
message.subIds.forEach { subId ->
updateSingle(subId, message.forcedUpdate)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "SubscriptionUpdateService update failed", e)
} finally {
if (runningTasks.decrementAndGet() == 0 && activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this@SubscriptionUpdateService)
stopSelf()
}
}
}
}
}
private suspend fun updateSingle(subId: String, forcedUpdate: Boolean) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
if (!subItem.enabled || subItem.url.isEmpty()) {
return
}
val sub = SubscriptionCache(subId, subItem)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Updating ${subItem.remarks}")
showNotification(
context = this,
titleResId = R.string.title_pref_auto_update_subscription,
content = "Updating ${subItem.remarks}"
)
if (forcedUpdate || MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)) {
AngConfigManager.updateConfigViaSub(sub)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)) {
testSubscriptionServers(sub)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: removing invalid servers for ${subItem.remarks}")
showNotification(
context = this,
titleResId = R.string.title_del_invalid_config,
content = subItem.remarks
)
AngConfigManager.removeInvalidServer(subId)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: sorting servers for ${subItem.remarks}")
showNotification(
context = this,
titleResId = R.string.title_sort_by_test_results,
content = subItem.remarks
)
AngConfigManager.sortByTestResultsForSub(subId)
}
}
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Finished ${subItem.remarks}")
}
private suspend fun testSubscriptionServers(sub: SubscriptionCache) {
val subId = sub.guid
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: starting test phase for ${sub.subscription.remarks}")
showNotification(
context = this,
titleResId = R.string.title_real_ping_all_server,
content = sub.subscription.remarks
)
val guids = MmkvManager.decodeServerList(subId)
if (guids.isNotEmpty()) {
val deferred = CompletableDeferred<Unit>()
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(
context = this,
guids = guids,
onEvent = { event ->
handleWorkerEvent(event, sub.subscription.remarks) {
activeWorkers.remove(worker)
deferred.complete(Unit)
}
}
)
activeWorkers.add(worker)
worker.start()
deferred.await()
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: test phase finished for ${sub.subscription.remarks}")
}
}
private fun handleWorkerEvent(event: RealPingEvent, remarks: String, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
val text = "${event.text} in $remarks"
showNotification(
context = this,
titleResId = R.string.title_real_ping_all_server,
content = text
)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: $text")
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
}
is RealPingEvent.Finish -> {
onWorkerDone()
}
}
}
private fun showNotification(context: Context, titleResId: Int, content: String) {
NotificationHelper.notify(
NotificationChannelType.SUBSCRIPTION_UPDATE,
context,
context.getString(titleResId),
content
)
}
}
@@ -1,52 +1,131 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.webkit.WebView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.databinding.ActivityAboutBinding
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.SettingsMenuItem
import com.v2ray.ang.ui.compose.VersionInfoBlock
import com.v2ray.ang.util.Utils
class AboutActivity : BaseActivity() {
private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
class AboutActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_about))
}
binding.layoutSoureCcode.setOnClickListener {
Utils.openUri(this, AppConfig.APP_URL)
}
@Composable
override fun ScreenContent() {
AboutScreen(onBackClick = { finish() })
}
}
binding.layoutFeedback.setOnClickListener {
Utils.openUri(this, AppConfig.APP_ISSUES_URL)
}
@Composable
fun AboutScreen(onBackClick: () -> Unit) {
val context = LocalContext.current
var showOssDialog by remember { mutableStateOf(false) }
binding.layoutOssLicenses.setOnClickListener {
val webView = android.webkit.WebView(this)
webView.loadUrl("file:///android_asset/open_source_licenses.html")
android.app.AlertDialog.Builder(this)
.setTitle("Open source licenses")
.setView(webView)
.setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
.show()
}
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
val appIdText = BuildConfig.APPLICATION_ID
binding.layoutTgChannel.setOnClickListener {
Utils.openUri(this, AppConfig.TG_CHANNEL_URL)
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_about),
onBackClick = onBackClick
)
}
binding.layoutPrivacyPolicy.setOnClickListener {
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
}
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
BuildConfig.APPLICATION_ID.also {
binding.tvAppId.text = it
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
SettingsMenuItem(
icon = painterResource(R.drawable.ic_source_code_24dp),
title = stringResource(R.string.title_source_code),
onClick = { Utils.openUri(context, AppConfig.APP_URL) }
)
SettingsMenuItem(
icon = painterResource(R.drawable.license_24px),
title = stringResource(R.string.title_oss_license),
onClick = { showOssDialog = true }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_feedback_24dp),
title = stringResource(R.string.title_pref_feedback),
onClick = { Utils.openUri(context, AppConfig.APP_ISSUES_URL) }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_telegram_24dp),
title = stringResource(R.string.title_tg_channel),
onClick = { Utils.openUri(context, AppConfig.TG_CHANNEL_URL) }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_privacy_24dp),
title = stringResource(R.string.title_privacy_policy),
onClick = { Utils.openUri(context, AppConfig.APP_PRIVACY_POLICY) }
)
VersionInfoBlock(
versionText = versionText,
appIdText = appIdText
)
}
}
}
if (showOssDialog) {
AlertDialog(
onDismissRequest = { showOssDialog = false },
title = { Text(stringResource(R.string.title_oss_license)) },
text = {
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
loadUrl("file:///android_asset/open_source_licenses.html")
}
},
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 300.dp)
)
},
confirmButton = {
TextButton(onClick = { showOssDialog = false }) {
Text(stringResource(android.R.string.ok))
}
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.padding(bottom = 60.dp)
)
}
}
@@ -1,197 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityAppPickerBinding
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
class AppPickerActivity : BaseActivity() {
companion object {
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
private const val EXTRA_PICKER_TITLE = "picker_title"
fun createIntent(
context: Context,
selectedPackages: Collection<String> = emptyList(),
title: String? = null
): Intent = Intent(context, AppPickerActivity::class.java).apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(selectedPackages))
title?.let { putExtra(EXTRA_PICKER_TITLE, it) }
}
fun getSelectedPackages(intent: Intent?): List<String> {
return intent?.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
}
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
private val initialSelectedPackages by lazy {
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
private val selectedPackages = LinkedHashSet<String>()
private var appsAll: List<AppInfo> = emptyList()
private val adapter = AppSelectorAdapter(selectedPackages)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
selectedPackages.addAll(initialSelectedPackages)
setupRecyclerView()
loadApps()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_app_picker, menu)
val searchItem = menu.findItem(R.id.search_view)
if (searchItem != null) {
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
filterApps(newText.orEmpty())
return false
}
})
}
return true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.select_all -> {
selectAllVisible()
true
}
R.id.invert_selection -> {
invertVisibleSelection()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun finish() {
setResult(
RESULT_OK,
Intent().apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
}
)
super.finish()
}
private fun setupRecyclerView() {
binding.recyclerView.adapter = adapter
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
}
@SuppressLint("UseCompatLoadingForDrawables")
private fun createSpecialItemUnidentified(): AppInfo {
val icon = requireNotNull(
getDrawable(android.R.drawable.ic_menu_help)
?: getDrawable(android.R.drawable.sym_def_app_icon)
) { "No fallback drawable available" }
return AppInfo(
appName = getString(R.string.app_picker_unknown_app),
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false,
isSelected = 0
)
}
private fun loadApps() {
showLoading()
lifecycleScope.launch {
try {
val apps = withContext(Dispatchers.IO) {
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
val sortedApps = sortApps(appsList)
listOf(createSpecialItemUnidentified()) + sortedApps
}
appsAll = apps
updateDisplayedApps(apps)
} catch (e: Exception) {
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
} finally {
hideLoading()
}
}
}
private fun filterApps(content: String) {
val key = content.uppercase()
val filteredApps = appsAll.filter { app ->
key.isEmpty() || matchesSearch(app, key)
}
updateDisplayedApps(filteredApps)
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
return apps.sortedWith { p1, p2 ->
val p1Selected = selectedPackages.contains(p1.packageName)
val p2Selected = selectedPackages.contains(p2.packageName)
when {
p1Selected && !p2Selected -> -1
!p1Selected && p2Selected -> 1
p1.isSystemApp && !p2.isSystemApp -> 1
!p1.isSystemApp && p2.isSystemApp -> -1
else -> collator.compare(p1.appName, p2.appName)
}
}
}
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
}
private fun updateDisplayedApps(apps: List<AppInfo>) {
adapter.submitList(apps)
}
private fun selectAllVisible() {
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
adapter.refreshSelection()
}
private fun invertVisibleSelection() {
adapter.apps.forEach { app ->
if (selectedPackages.contains(app.packageName)) {
selectedPackages.remove(app.packageName)
} else {
selectedPackages.add(app.packageName)
}
}
adapter.refreshSelection()
}
private fun getSelectedPackages(): ArrayList<String> {
return ArrayList(selectedPackages.sorted())
}
private fun resolveScreenTitle(): String {
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
}
}
@@ -1,64 +0,0 @@
package com.v2ray.ang.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo
class AppSelectorAdapter(
private val selectedPackages: MutableSet<String>
) : RecyclerView.Adapter<AppSelectorAdapter.AppViewHolder>() {
var apps: List<AppInfo> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
val binding = ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AppViewHolder(binding)
}
override fun getItemCount(): Int = apps.size
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
holder.bind(apps[position])
}
fun submitList(newApps: List<AppInfo>) {
apps = newApps
notifyDataSetChanged()
}
fun refreshSelection() {
notifyDataSetChanged()
}
inner class AppViewHolder(private val binding: ItemRecyclerBypassListBinding) : RecyclerView.ViewHolder(binding.root),
View.OnClickListener {
private lateinit var appInfo: AppInfo
fun bind(item: AppInfo) {
appInfo = item
binding.icon.setImageDrawable(item.appIcon)
binding.name.text = if (item.isSystemApp) {
String.format("** %s", item.appName)
} else {
item.appName
}
binding.packageName.text = item.packageName
binding.checkBox.isChecked = selectedPackages.contains(item.packageName)
itemView.setOnClickListener(this)
}
override fun onClick(v: View?) {
val packageName = appInfo.packageName
if (selectedPackages.contains(packageName)) {
selectedPackages.remove(packageName)
} else {
selectedPackages.add(packageName)
}
binding.checkBox.isChecked = selectedPackages.contains(packageName)
}
}
}
@@ -1,314 +0,0 @@
package com.v2ray.ang.ui
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WEBDAV_BACKUP_FILE_NAME
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBackupBinding
import com.v2ray.ang.databinding.DialogWebdavBinding
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.WebDavManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
class BackupActivity : HelperBaseActivity() {
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
private val config_backup_options: Array<out String> by lazy {
resources.getStringArray(R.array.config_backup_options)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
binding.layoutBackup.setOnClickListener {
AlertDialog.Builder(this)
.setTitle(R.string.title_configuration_backup)
.setItems(config_backup_options) { dialog, which ->
when (which) {
0 -> backupViaLocal()
1 -> backupViaWebDav()
}
}
.show()
}
binding.layoutShare.setOnClickListener {
val ret = backupConfigurationToCache()
if (ret.first) {
startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).setType("application/zip")
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(
Intent.EXTRA_STREAM,
FileProvider.getUriForFile(
this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
)
), getString(R.string.title_configuration_share)
)
)
} else {
toastError(R.string.toast_failure)
}
}
binding.layoutRestore.setOnClickListener {
AlertDialog.Builder(this)
.setTitle(R.string.title_configuration_restore)
.setItems(config_backup_options) { dialog, which ->
when (which) {
0 -> restoreViaLocal()
1 -> restoreViaWebDav()
}
}
.show()
}
binding.layoutWebdavConfigSetting.setOnClickListener {
showWebDavSettingsDialog()
}
}
/**
* Backup configuration to cache directory
* Returns Pair<success, zipFilePath>
*/
private fun backupConfigurationToCache(): Pair<Boolean, String> {
val dateFormatted = SimpleDateFormat(
"yyyy-MM-dd-HH-mm-ss",
Locale.getDefault()
).format(System.currentTimeMillis())
val folderName = "${getString(R.string.app_name)}_${dateFormatted}"
val backupDir = this.cacheDir.absolutePath + "/$folderName"
val outputZipFilePath = "${this.cacheDir.absolutePath}/$folderName.zip"
val count = MMKV.backupAllToDirectory(backupDir)
if (count <= 0) {
return Pair(false, "")
}
if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
return Pair(true, outputZipFilePath)
} else {
return Pair(false, "")
}
}
private fun restoreConfiguration(zipFile: File): Boolean {
val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
return false
}
val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService()
SettingsManager.initApp(this)
return count > 0
}
private fun showFileChooser() {
launchFileChooser { uri ->
if (uri == null) {
return@launchFileChooser
}
try {
val targetFile =
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
contentResolver.openInputStream(uri).use { input ->
targetFile.outputStream().use { fileOut ->
input?.copyTo(fileOut)
}
}
if (restoreConfiguration(targetFile)) {
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
}
}
}
private fun backupViaLocal() {
val dateFormatted = SimpleDateFormat(
"yyyy-MM-dd-HH-mm-ss",
Locale.getDefault()
).format(System.currentTimeMillis())
val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
launchCreateDocument(defaultFileName) { uri ->
if (uri != null) {
try {
val ret = backupConfigurationToCache()
if (ret.first) {
// Copy the cached zip file to user-selected location
contentResolver.openOutputStream(uri)?.use { output ->
File(ret.second).inputStream().use { input ->
input.copyTo(output)
}
}
// Clean up cache file
File(ret.second).delete()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
}
}
private fun restoreViaLocal() {
showFileChooser()
}
private fun backupViaWebDav() {
val saved = MmkvManager.decodeWebDavConfig()
if (saved == null || saved.baseUrl.isEmpty()) {
toastError(R.string.title_webdav_config_setting_unknown)
return
}
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
var tempFile: File? = null
try {
val ret = backupConfigurationToCache()
if (!ret.first) {
withContext(Dispatchers.Main) {
toastError(R.string.toast_failure)
}
return@launch
}
tempFile = File(ret.second)
WebDavManager.init(saved)
val ok = try {
WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "WebDAV upload error", e)
false
}
withContext(Dispatchers.Main) {
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "WebDAV backup error", e)
withContext(Dispatchers.Main) {
toastError(R.string.toast_failure)
}
} finally {
try {
tempFile?.delete()
} catch (_: Exception) {
}
withContext(Dispatchers.Main) {
hideLoading()
}
}
}
}
private fun restoreViaWebDav() {
val saved = MmkvManager.decodeWebDavConfig()
if (saved == null || saved.baseUrl.isEmpty()) {
toastError(R.string.title_webdav_config_setting_unknown)
return
}
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
var target: File? = null
try {
target = File(cacheDir, "download_${System.currentTimeMillis()}.zip")
WebDavManager.init(saved)
val ok = WebDavManager.downloadFile(WEBDAV_BACKUP_FILE_NAME, target)
if (!ok) {
withContext(Dispatchers.Main) {
toastError(R.string.toast_failure)
}
return@launch
}
val restored = restoreConfiguration(target)
withContext(Dispatchers.Main) {
if (restored) {
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "WebDAV download error", e)
withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
} finally {
try {
target?.delete()
} catch (_: Exception) {
}
withContext(Dispatchers.Main) {
hideLoading()
}
}
}
}
private fun showWebDavSettingsDialog() {
val dialogBinding = DialogWebdavBinding.inflate(layoutInflater)
MmkvManager.decodeWebDavConfig()?.let { cfg ->
dialogBinding.etWebdavUrl.setText(cfg.baseUrl)
dialogBinding.etWebdavUser.setText(cfg.username ?: "")
dialogBinding.etWebdavPass.setText(cfg.password ?: "")
dialogBinding.etWebdavRemotePath.setText(cfg.remoteBasePath ?: "/")
}
AlertDialog.Builder(this)
.setTitle(R.string.title_webdav_config_setting)
.setView(dialogBinding.root)
.setPositiveButton(R.string.menu_item_save_config) { _, _ ->
val url = dialogBinding.etWebdavUrl.text.toString().trim()
val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
val pass = dialogBinding.etWebdavPass.text.toString()
val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
MmkvManager.encodeWebDavConfig(cfg)
toastSuccess(R.string.toast_success)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
Loaded 100 of 302 files, more files were not shown because too many files have changed in this diff. Show more