Compare commits

...
131 Commits
Author SHA1 Message Date
2dust 19b0c9a2c3 up 2.3.2 2026-08-02 15:17:48 +08:00
2dust db0755cf0c Auto delete and sort subscription after core tests 2026-08-02 15:14:40 +08:00
mola dc6a7eae93 fix: hide scrollbars during activity transitions (#6003) 2026-08-02 14:57:22 +08:00
2dust 3ae8db972e Fix ipv6 hosts
https://github.com/2dust/v2rayNG/issues/5965
2026-08-02 14:53:57 +08:00
2dust a899d7f444 Automated testing takes a long time. If enabled, the task will run in the background; otherwise, it will run on the current page.
https://github.com/2dust/v2rayNG/issues/5999
2026-08-02 14:42:00 +08:00
dkgksandroot c8f78621bf fix: restore XHTTP/gRPC/KCP transport settings in Compose server UI (#5995)
The Compose UI migration (ServerUiState + BaseServerActivity) dropped or
misbound several transport-specific fields that the legacy editor handled
correctly:

1. XHTTP mode (auto/packet-up/stream-up/stream-one) — stored in
   ProfileItem.xhttpMode, but ServerUiState had no field and the dropdown
   was bound to state.headerType (default "none")
2. gRPC mode (gun/multi) — stored in ProfileItem.mode, same problem
3. gRPC serviceName/authority — the "gRPC Authority" field was bound to
   state.host and "gRPC serviceName" to state.path, but the core outbound
   builder reads ProfileItem.authority/serviceName. Editing + saving a
   gRPC node wrote to host/path, breaking the connection
4. KCP seed — no field in ServerUiState, no input in the Compose UI

Changes (2 files):

ServerUiState.kt:
- Added constructor params + mutableState for: mode, xhttpMode,
  serviceName, authority, seed
- fromProfileItem(): read all five from initialConfig
- toProfileItem(): write all five with .nullIfBlank()

BaseServerActivity.kt CommonNetworkFields:
- Mode dropdown dispatches by network: GRPC -> state.mode,
  XHTTP -> state.xhttpMode, else -> state.headerType (TCP/KCP unchanged)
- Host/Authority field: GRPC -> state.authority, else -> state.host
- Path/serviceName field: GRPC -> state.serviceName, else -> state.path
- KCP: added seed text field, hide the path field (KCP doesn't use path;
  previously it showed a second "kcp seed" label bound to the wrong field)

Null handling is safe: XhttpSettingsBean.mode is nullable and Gson omits
null fields, so xray-core falls back to its default mode; gRPC
multiMode = (mode == "multi") is null-safe (false -> default gun).

Fixes #5993

Signed-off-by: dkgks

Signed-off-by: dkgks
Co-authored-by: root <root@debianG>
2026-08-02 10:41:49 +08:00
vemneyy 0cf95ac8a3 fix: tunnel dying some time after a network handover (#5994) 2026-08-02 10:35:36 +08:00
2dust 83abd61a7b up 2.3.1 2026-08-01 15:20:00 +08:00
2dust 4dc3a20a5c Update TProxy JNI signatures with return types 2026-08-01 15:19:30 +08:00
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
285 changed files with 17159 additions and 13772 deletions

No files matched your search

+44 -11
View File
@@ -10,13 +10,16 @@ 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'
@@ -40,7 +43,7 @@ jobs:
- name: Restore cached libhevtun
id: cache-libhevtun-restore
uses: actions/cache/restore@v5
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@v5
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.21-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)
+25 -28
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.jaredsburrows.license")
}
@@ -12,16 +13,15 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 733
versionName = "2.2.3"
multiDexEnabled = true
versionCode = 742
versionName = "2.3.2"
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>
+80 -26
View File
@@ -29,6 +29,7 @@
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.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
@@ -52,7 +53,7 @@
android:usesCleartextTraffic="true">
<activity
android:name=".ui.MainActivity"
android:name=".ui.main.MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
@@ -70,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"
@@ -161,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"
@@ -201,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"
@@ -211,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"
@@ -230,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>
@@ -250,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>
@@ -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"
@@ -69,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"
@@ -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,9 +185,13 @@ 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://"
@@ -190,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"
@@ -203,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
@@ -223,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
@@ -287,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.entities.ProfileItem
interface MainAdapterListener : BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
fun onSelectServer(guid: String)
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
}
@@ -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
}
@@ -4,6 +4,7 @@ import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BalancerStrategyType
import com.v2ray.ang.enums.CoreResolvedType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
@@ -43,11 +44,15 @@ object CoreConfigContextBuilder {
// 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 = listOf(primaryResolvedOutbound) + routingResolvedOutbounds,
resolvedOutbounds = resolvedOutbounds + fallbackResolvedOutbounds,
routingDomainRules = routingDomainRules,
)
}
@@ -214,4 +219,55 @@ object CoreConfigContextBuilder {
return listOf(config)
}
}
/**
* Collect enabled routing domain rules in original order for DNS segmentation.
*
* outbounds are normalized into three tags only: proxy / direct / block.
*/
private fun collectRoutingDomainRulesForDns(): List<CoreConfigContext.RoutingDomainRule> {
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
val result = mutableListOf<CoreConfigContext.RoutingDomainRule>()
rulesetItems
.asSequence()
.filter { it.enabled }
.filter { !it.domain.isNullOrEmpty() }
.forEach { rule ->
val normalizedOutboundTag = when (rule.outboundTag) {
AppConfig.TAG_DIRECT -> AppConfig.TAG_DIRECT
AppConfig.TAG_BLOCKED -> AppConfig.TAG_BLOCKED
else -> AppConfig.TAG_PROXY
}
result.add(
CoreConfigContext.RoutingDomainRule(
domain = rule.domain.orEmpty(),
outboundTag = normalizedOutboundTag
)
)
}
return result
}
/**
* Resolve and collect fallback outbounds from all POLICYGROUP nodes.
*
* Fallback targets must not overlap with already resolved tags or builtin tags.
*/
private fun resolveFallbackOutbounds(resolvedOutbounds: List<CoreConfigContext.ResolvedOutbound>): List<CoreConfigContext.ResolvedOutbound> {
return resolvedOutbounds
.asSequence()
.filter { it.resolvedType == CoreResolvedType.POLICYGROUP }
.filter { BalancerStrategyType.from(it.profile.policyGroupType).supportsObservatory && it.profile.policyGroupTestOutbounds != false }
.mapNotNull { it.profile.policyGroupFallbackTag }
.filter { it !in AppConfig.BUILTIN_OUTBOUND_TAGS && resolvedOutbounds.none { outbound -> outbound.tag == it } }
.distinct()
.mapNotNull { tag ->
SettingsManager.getServerViaRemarks(tag)
?.takeUnless { it.configType == EConfigType.CUSTOM || it.configType == EConfigType.POLICYGROUP }
?.let { resolveOutbound(tag, it) }
}
.toList()
}
}
@@ -166,8 +166,9 @@ object CoreConfigManager {
// User routing rules (policyGroupBalancerTags rewrites TAG_PROXY→balancer when main is POLICYGROUP).
configureRouting(configContext, v2rayConfig, policyGroupBalancerTags)
configureFakeDns(v2rayConfig)
configureDns(v2rayConfig, policyGroupBalancerTags)
configureLocalDns(v2rayConfig)
configureDns(configContext, v2rayConfig, policyGroupBalancerTags)
configureLocalDns(configContext, v2rayConfig)
configureRootModeDns(v2rayConfig)
// (added by getDns / getCustomLocalDns) to use the balancer, then add
// the catch-all balancer rule.
@@ -373,10 +374,19 @@ object CoreConfigManager {
} else {
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
}
val strategyType = BalancerStrategyType.from(resolvedOutbound.profile.policyGroupType)
val fallbackTag = if (strategyType.supportsObservatory && resolvedOutbound.profile.policyGroupTestOutbounds != false) {
resolvedOutbound.profile.policyGroupFallbackTag
?.takeIf { it.isNotEmpty() && it != AppConfig.TAG_PROXY }
// Xray excludes dead random/roundRobin candidates only when fallbackTag is set;
// without this default, an enabled empty field creates no observatory.
?: membersToAdd.first().tag
} else null
val strategy = buildBalancerStrategy(
policyGroupType = resolvedOutbound.profile.policyGroupType,
strategyType = strategyType,
selector = listOf(memberTagPrefix),
balancerTag = balancerTag,
fallbackTag = fallbackTag,
)
val existingBalancers = v2rayConfig.routing.balancers?.toMutableList() ?: mutableListOf()
if (existingBalancers.none { it.tag == balancerTag }) {
@@ -452,8 +462,10 @@ object CoreConfigManager {
val vpn = SettingsManager.isVpnMode()
val useHev = SettingsManager.isUsingHevTun()
val forcedByHev = vpn && useHev
val forcedBySocksRoot = SettingsManager.isRootMode()
|| MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
val enableLocalProxy = forcedByHev || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
val enableLocalProxy = forcedByHev || forcedBySocksRoot || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername()
@@ -567,16 +579,20 @@ object CoreConfigManager {
/**
* Configure local DNS inbounds, outbounds, and routing rules.
*/
private fun configureLocalDns(v2rayConfig: V2rayConfig) {
private fun configureLocalDns(configContext: CoreConfigContext, v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) != true) {
return
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
val proxyDomain = collectUserRuleDomainsByTag(AppConfig.TAG_PROXY)
val directDomain = collectUserRuleDomainsByTag(AppConfig.TAG_DIRECT)
val finalDomain = geositeCn.plus(proxyDomain).plus(directDomain).distinct()
val routingDomains = configContext.routingDomainRules
.asSequence()
.filter { it.outboundTag != AppConfig.TAG_BLOCKED }
.flatMap { it.domain.asSequence() }
.toList()
.distinct()
val finalDomain = geositeCn + routingDomains
// fakedns with all domains to make it always top priority
v2rayConfig.dns?.servers?.add(
0,
@@ -622,6 +638,39 @@ object CoreConfigManager {
}
}
/**
* In the root mode the whole device's traffic (incl. raw DNS) is funneled
* into the core's SOCKS inbound, exactly like the VPN+hev path. Hijack port-53 to the
* core's DNS module so queries are resolved via the configured resolver through the
* proxy instead of leaking to (or being mis-resolved by) the local network resolver.
* Independent of the local-DNS toggle, which is not exposed for root mode.
*/
private fun configureRootModeDns(v2rayConfig: V2rayConfig) {
if (!SettingsManager.isRootMode()) return
if (v2rayConfig.routing.rules.none { it.outboundTag == "dns-out" && it.port == "53" }) {
v2rayConfig.routing.rules.add(
0,
V2rayConfig.RoutingBean.RulesBean(
inboundTag = arrayListOf("socks"),
outboundTag = "dns-out",
port = "53",
)
)
}
if (v2rayConfig.outbounds.none { it.protocol == "dns" && it.tag == "dns-out" }) {
v2rayConfig.outbounds.add(
V2rayConfig.OutboundBean(
protocol = "dns",
tag = "dns-out",
settings = null,
streamSettings = null,
mux = null
)
)
}
}
/**
* Remove speed-test runtime sections when the feature is disabled.
*/
@@ -632,6 +681,7 @@ object CoreConfigManager {
}
}
/*
/**
* Configure DNS servers, hosts, and DNS routing rules.
*/
@@ -710,12 +760,18 @@ object CoreConfigManager {
// hardcode popular Android Private DNS rule to fix localhost DNS problem
hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
hosts[AppConfig.DNS_CISCO_SSE_DOMAIN] = AppConfig.DNS_CISCO_SSE_ADDRESSES
hosts[AppConfig.DNS_CISCO_UMBRELLA_DOMAIN] = AppConfig.DNS_CISCO_UMBRELLA_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_ONEDOT_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONEDOT_DNS_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_WARP_DOMAIN] = AppConfig.DNS_CLOUDFLARE_WARP_ADDRESSES
hosts[AppConfig.DNS_DNSPOD_DOH_DOMAIN] = AppConfig.DNS_DNSPOD_DOH_ADDRESSES
hosts[AppConfig.DNS_DNSPOD_DOT_DOMAIN] = AppConfig.DNS_DNSPOD_DOT_ADDRESSES
hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
hosts[AppConfig.DNS_SB_DOMAIN] = AppConfig.DNS_SB_ADDRESSES
hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
//User DNS hosts
@@ -765,7 +821,194 @@ object CoreConfigManager {
)
}
}
*/
/**
* Configure DNS servers, hosts, and DNS routing rules.
*/
private fun configureDns(
configContext: CoreConfigContext,
v2rayConfig: V2rayConfig,
policyGroupBalancerTags: Map<String, String>,
) {
val servers = ArrayList<Any>()
val remoteDns = SettingsManager.getRemoteDnsServers()
val domesticDns = SettingsManager.getDomesticDnsServers()
remoteDns.forEach { servers.add(it) }
val hosts = buildDnsHostsFromRoutingRules(configContext)
val cnDomesticDnsTags = buildDnsCnModeFromRoutingRules(configContext, servers, domesticDns)
val domesticDnsTags = buildDnsFromRoutingRules(
configContext = configContext,
servers = servers,
remoteDns = remoteDns,
domesticDns = domesticDns
)
domesticDnsTags.addAll(cnDomesticDnsTags)
v2rayConfig.dns = V2rayConfig.DnsBean(
servers = servers,
hosts = hosts,
tag = AppConfig.TAG_DNS,
enableParallelQuery = if ((domesticDns.size + remoteDns.size) > 2) true else null
)
if (domesticDnsTags.isNotEmpty()) {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
outboundTag = AppConfig.TAG_DIRECT,
inboundTag = ArrayList(domesticDnsTags),
domain = null
)
)
}
val dnsProxyBalancerTag = policyGroupBalancerTags[AppConfig.TAG_PROXY]
if (dnsProxyBalancerTag != null) {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
balancerTag = dnsProxyBalancerTag,
inboundTag = arrayListOf(AppConfig.TAG_DNS),
domain = null
)
)
} else {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
outboundTag = AppConfig.TAG_PROXY,
inboundTag = arrayListOf(AppConfig.TAG_DNS),
domain = null
)
)
}
}
private fun buildDnsHostsFromRoutingRules(configContext: CoreConfigContext): MutableMap<String, Any> {
val hosts = mutableMapOf<String, Any>()
val blockDomains = configContext.routingDomainRules
.asSequence()
.filter { it.outboundTag == AppConfig.TAG_BLOCKED }
.flatMap { it.domain.asSequence() }
.toList()
if (blockDomains.isNotEmpty()) {
hosts.putAll(blockDomains.map { it to AppConfig.LOOPBACK })
}
hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
hosts[AppConfig.DNS_CISCO_SSE_DOMAIN] = AppConfig.DNS_CISCO_SSE_ADDRESSES
hosts[AppConfig.DNS_CISCO_UMBRELLA_DOMAIN] = AppConfig.DNS_CISCO_UMBRELLA_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_ONEDOT_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONEDOT_DNS_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_WARP_DOMAIN] = AppConfig.DNS_CLOUDFLARE_WARP_ADDRESSES
hosts[AppConfig.DNS_DNSPOD_DOH_DOMAIN] = AppConfig.DNS_DNSPOD_DOH_ADDRESSES
hosts[AppConfig.DNS_DNSPOD_DOT_DOMAIN] = AppConfig.DNS_DNSPOD_DOT_ADDRESSES
hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
hosts[AppConfig.DNS_SB_DOMAIN] = AppConfig.DNS_SB_ADDRESSES
hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
if (userHosts.isNotNullEmpty()) {
val userHostsMap = userHosts?.split(",").orEmpty()
.filter { it.isNotBlank() && it.contains(":") }
.associate {
// Use limit = 2 to split only at the first colon.
// This ensures that IPv6 addresses (which contain multiple colons)
// are preserved entirely in the second part.
val parts = it.split(":", limit = 2)
parts[0].trim() to parts[1].trim()
}
hosts.putAll(userHostsMap)
}
return hosts
}
private fun buildDnsCnModeFromRoutingRules(configContext: CoreConfigContext, servers: ArrayList<Any>, domesticDns: List<String>): List<String> {
val cnRegionFilter = { domain: String ->
domain.startsWith("geosite:") && (domain.endsWith("-cn") || domain.endsWith("@cn"))
|| domain == AppConfig.GEOSITE_CN
}
val isCnRoutingMode = configContext.routingDomainRules
.asSequence()
.filter { it.outboundTag == AppConfig.TAG_DIRECT }
.flatMap { it.domain.asSequence() }
.any { it == AppConfig.GEOSITE_CN }
if (!isCnRoutingMode) {
return emptyList()
}
val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
val cnDomains = configContext.routingDomainRules
.asSequence()
.filter { it.outboundTag == AppConfig.TAG_DIRECT }
.flatMap { it.domain.asSequence() }
.filter { cnRegionFilter(it) }
.toList()
if (cnDomains.isEmpty()) {
return emptyList()
}
val cnDomesticDnsTags = mutableListOf<String>()
domesticDns.forEachIndexed { index, address ->
val cnDomesticDnsTag = "${AppConfig.TAG_DOMESTIC_DNS}_cn_expect_${index}"
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = address,
domains = cnDomains,
expectIPs = geoipCn,
skipFallback = true,
tag = cnDomesticDnsTag
)
)
cnDomesticDnsTags.add(cnDomesticDnsTag)
}
return cnDomesticDnsTags
}
private fun buildDnsFromRoutingRules(
configContext: CoreConfigContext,
servers: ArrayList<Any>,
remoteDns: List<String>,
domesticDns: List<String>,
): MutableList<String> {
val domesticDnsTags = mutableListOf<String>()
configContext.routingDomainRules.forEachIndexed { ruleIndex, rule ->
when (rule.outboundTag) {
AppConfig.TAG_DIRECT -> {
domesticDns.forEachIndexed { dnsIndex, address ->
val tag = "${AppConfig.TAG_DOMESTIC_DNS}_${ruleIndex}_$dnsIndex"
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = address,
domains = rule.domain,
skipFallback = true,
tag = tag
)
)
domesticDnsTags.add(tag)
}
}
AppConfig.TAG_BLOCKED -> Unit
else -> {
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = remoteDns.first(),
domains = rule.domain,
)
)
}
}
}
return domesticDnsTags
}
//endregion
@@ -951,22 +1194,28 @@ object CoreConfigManager {
* Build balancer and probe settings from one policy-group strategy value.
*/
private fun buildBalancerStrategy(
policyGroupType: String?,
strategyType: BalancerStrategyType,
selector: List<String>,
balancerTag: String = AppConfig.TAG_BALANCER,
fallbackTag: String? = null,
): BalancerStrategy {
val probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL
val strategyType = BalancerStrategyType.from(policyGroupType)
val leastPingInterval = decodeObservatoryDuration(AppConfig.PREF_OBSERVATORY_LEAST_PING_INTERVAL, AppConfig.OBSERVATORY_LEAST_PING_INTERVAL)
val leastLoadInterval = decodeObservatoryDuration(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_INTERVAL, AppConfig.OBSERVATORY_LEAST_LOAD_INTERVAL)
val leastLoadMethod = MmkvManager.decodeSettingsString(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_METHOD, AppConfig.OBSERVATORY_LEAST_LOAD_METHOD)
val leastLoadSampling = decodeObservatorySampling()
val leastLoadTimeout = decodeObservatoryDuration(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT, AppConfig.OBSERVATORY_LEAST_LOAD_TIMEOUT)
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = balancerTag,
selector = selector,
fallbackTag = fallbackTag,
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
)
val observatory = if (strategyType.requiresObservatory) {
val observatory = if (strategyType.requiresObservatory || fallbackTag != null) {
V2rayConfig.ObservatoryObject(
subjectSelector = selector,
probeUrl = probeUrl,
probeInterval = "3m",
probeInterval = leastPingInterval,
enableConcurrency = true
)
} else null
@@ -975,15 +1224,33 @@ object CoreConfigManager {
subjectSelector = selector,
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
destination = probeUrl,
interval = "5m",
sampling = 2,
timeout = "30s"
httpMethod = leastLoadMethod,
interval = leastLoadInterval,
sampling = leastLoadSampling,
timeout = leastLoadTimeout
)
)
} else null
return BalancerStrategy(balancer, observatory, burstObservatory)
}
private fun decodeObservatoryDuration(key: String, default: String): String {
val value = MmkvManager.decodeSettingsString(key)?.trim()
return if (!value.isNullOrEmpty() && AppConfig.OBSERVATORY_DURATION_PATTERN.matches(value)) {
value
} else {
default
}
}
private fun decodeObservatorySampling(): Int {
return MmkvManager.decodeSettingsString(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_SAMPLING)
?.trim()
?.toIntOrNull()
?.takeIf { it > 0 }
?: AppConfig.OBSERVATORY_LEAST_LOAD_SAMPLING.toInt()
}
/**
* Carry balancer data plus optional probe settings for later merge.
*/
@@ -994,4 +1261,4 @@ object CoreConfigManager {
)
//endregion
}
}
@@ -64,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 {
@@ -83,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
}
@@ -136,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 {
@@ -157,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 {
@@ -179,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 {
@@ -200,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 {
@@ -221,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()
}
}
@@ -238,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()
}
}
@@ -281,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
}
@@ -368,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
)
)
)
@@ -564,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(),
@@ -611,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(
@@ -624,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(
@@ -685,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,28 +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.OutboundTrafficStat
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
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
@@ -47,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) {
@@ -59,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.
@@ -120,90 +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.isComplexType()
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
error(context.getString(R.string.toast_config_file_invalid))
}
// refresh socks port when enabled dynamic socks port
SettingsManager.refreshRuntimeSocksPort()
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
if (config.insecure == true) {
context.toastError(R.string.toast_allow_insecure_deprecated)
context.toastError(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 isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, CoreVpnService::class.java)
} else {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: SecurityException) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Missing permission to start foreground service", e)
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
} catch (e: RuntimeException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Foreground service start not allowed", e)
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
}
throw e
}
}
/**
* Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
* `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
* Starts the V2Ray core service.
*/
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
if (coreController.isRunning) {
if (isRunning()) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false
}
@@ -220,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
}
@@ -228,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")
@@ -238,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")
}
@@ -267,15 +156,23 @@ 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, "")
if (!isReload) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
}
NotificationManager.startSpeedNotification()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
}
@@ -288,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()
@@ -305,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 {
@@ -317,11 +218,66 @@ object CoreServiceManager {
return true
}
/**
* Subscribes to upstream network changes for whichever run mode is active.
* All three services share this manager, so the tunnel recovers from a handover in proxy only
* and root mode as well, not just behind the VPN interface.
*/
private fun startNetworkMonitor(service: Service) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
if (networkMonitor != null) return
val connectivity = service.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
networkMonitor = NetworkMonitor(
connectivity = connectivity,
onUnderlyingNetworksChanged = { networks -> serviceControl?.get()?.setUnderlyingNetworks(networks) },
onHandover = { reloadCore() },
).also { it.register() }
}
/**
* Restarts the core in place after the upstream network changed: the service, the notification
* and the VPN interface all stay up, so nothing of this is visible.
*
* The config is rebuilt on purpose, outbound server domains are resolved while building it and
* an address resolved on a network that is gone can be unusable on the new one.
*
* @return True if the core is running again.
*/
private fun reloadCore(): Boolean {
if (isReloading) return false
val service = getService() ?: return false
if (!isRunning()) return false
return try {
val tunFd = currentVpnInterface
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
}
}
/**
* 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>()
@@ -352,7 +308,7 @@ object CoreServiceManager {
* Also fetches remote IP information if the delay test was successful.
*/
private fun measureV2rayDelay() {
if (coreController.isRunning == false) {
if (!isRunning()) {
return
}
@@ -381,12 +337,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")
}
}
}
@@ -491,10 +447,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, "")
}
}
@@ -515,7 +471,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 -> {
@@ -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 = "",
)
@@ -9,6 +9,7 @@ data class CoreConfigContext(
val guid: String,
val isCustom: Boolean = false,
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
val routingDomainRules: List<RoutingDomainRule> = emptyList(),
) {
data class ResolvedOutbound(
val tag: String,
@@ -16,4 +17,9 @@ data class CoreConfigContext(
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,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
@@ -6,5 +6,6 @@ data class UrlContentRequest(
val httpPort: Int = 0,
val proxyUsername: String? = null,
val proxyPassword: String? = null,
val userAgent: String? = null
val userAgent: String? = null,
val requestHeaders: String? = null
)
@@ -72,65 +72,34 @@ data class V2rayConfig(
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*/
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,
@@ -139,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,
@@ -253,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,
@@ -295,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
@@ -338,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 {
@@ -493,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 = ""
)
@@ -36,12 +36,14 @@ data class ProfileItem(
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,
@@ -58,6 +60,7 @@ data class ProfileItem(
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,
@@ -65,66 +68,45 @@ data class ProfileItem(
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 {
return ProfileItem(configType = configType)
}
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
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
)
}
}
/**
* 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.entities
data class RulesetItem(
var id: String = "",
var remarks: String? = "",
var ip: List<String>? = null,
var domain: List<String>? = null,
@@ -2,5 +2,7 @@ package com.v2ray.ang.dto.entities
data class ServersCache(
val guid: String,
val profile: ProfileItem
)
val profile: ProfileItem,
val testDelayMillis: Long = 0L,
val testDelayString: String = "",
)
@@ -13,5 +13,6 @@ data class SubscriptionItem(
var filter: String? = null,
var allowInsecureUrl: Boolean = false,
var userAgent: String? = null,
var requestHeaders: String? = null,
)
@@ -7,20 +7,22 @@ package com.v2ray.ang.enums
* @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"),
ROUND_ROBIN("roundRobin", "3"),
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 }
}
}
@@ -17,6 +17,12 @@ enum class PermissionType {
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 */
@@ -27,6 +33,7 @@ enum class PermissionType {
return when (this) {
CAMERA -> "Camera"
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,76 +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 com.v2ray.ang.enums.EConfigType
import es.dmoral.toasty.Toasty
import java.io.Serializable
import java.net.URI
import java.util.Locale
val Context.v2RayApplication: AngApplication?
get() = applicationContext as? AngApplication
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toast(message: Int) {
Toasty.normal(this, message).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toast(message: CharSequence) {
Toasty.normal(this, message).show()
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastSuccess(message: Int) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastSuccess(message: CharSequence) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastError(message: Int) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
}
const val THRESHOLD = 1000L
const val DIVISOR = 1024.0
@@ -100,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,37 +59,6 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
else -> @Suppress("DEPRECATION") getSerializableExtra(key) as? T
}
/**
* Checks if the CharSequence is not null and not empty.
*
* @return True if the CharSequence is not null and not empty, false otherwise.
*/
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
fun String.concatUrl(vararg paths: String): String {
val builder = StringBuilder(this.trimEnd('/'))
paths.forEach { path ->
val trimmedPath = path.trim('/')
if (trimmedPath.isNotEmpty()) {
builder.append('/').append(trimmedPath)
}
}
return builder.toString()
}
/**
* Helper function to match text either by Regex or literal string.
*/
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
if (keyword.isNullOrEmpty()) {
return true
}
return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase)
}
/**
* Checks if the config type is a group type (PolicyGroup or ProxyChain).
*
@@ -84,6 +84,7 @@ open class FmtBase {
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"]
@@ -104,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 }
@@ -7,7 +7,6 @@ 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
@@ -37,9 +36,6 @@ object Hysteria2Fmt : FmtBase() {
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"]
}
@@ -68,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()
}
@@ -5,7 +5,6 @@ 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
@@ -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.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
@@ -8,7 +8,6 @@ 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
@@ -88,6 +87,9 @@ object VmessFmt : FmtBase() {
"0" -> false
else -> false
}
config.verifyPeerCertByName = vmessQRCode.vcn
config.pinnedCA256 = vmessQRCode.pcs
return config
}
@@ -141,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)
@@ -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
@@ -42,7 +43,8 @@ object AngConfigManager {
EConfigType.VLESS.protocolScheme to VlessFmt::parse,
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
AppConfig.HY2 to Hysteria2Fmt::parse
AppConfig.HY2 to Hysteria2Fmt::parse,
AppConfig.V2RAYNFMTS to V2rayNFmt::parse
)
}
@@ -494,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)
@@ -550,6 +558,7 @@ 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()
@@ -559,6 +568,7 @@ object AngConfigManager {
UrlContentRequest(
url = url,
userAgent = userAgent,
requestHeaders = requestHeaders,
timeout = 15000,
httpPort = httpPort,
proxyUsername = proxyUsername,
@@ -574,7 +584,8 @@ object AngConfigManager {
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent
userAgent = userAgent,
requestHeaders = requestHeaders
)
)
} catch (e: Exception) {
@@ -605,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,5 +1,11 @@
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
@@ -14,6 +20,9 @@ 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 {
@@ -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.
*
@@ -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
}
@@ -718,4 +756,56 @@ object MmkvManager {
}
//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
}
@@ -16,7 +16,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.ui.MainActivity
import com.v2ray.ang.ui.main.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -93,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)
@@ -105,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
)
@@ -115,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.
*/
@@ -147,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
@@ -278,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
@@ -20,6 +19,7 @@ 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
@@ -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.
@@ -439,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
@@ -487,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.
*/
@@ -527,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) {
@@ -622,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)
}
}
}
@@ -6,8 +6,6 @@ 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
@@ -15,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.
*
@@ -45,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
@@ -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.entities.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()
}
}
}
}
@@ -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)
}
}
@@ -10,10 +10,11 @@ import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
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 +60,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 +85,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,7 +96,8 @@ class CoreTestService : Service() {
worker = RealPingWorkerService(
context = this,
guids = guidsList,
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
onlyTcp = message.onlyTcp,
onEvent = { event -> handleWorkerEvent(event, message) { activeWorkers.remove(worker) } }
)
activeWorkers.add(worker)
worker.start()
@@ -106,24 +107,35 @@ class CoreTestService : Service() {
}
}
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
channelType = NotificationChannelType.CORE_TEST,
context = this,
title = getString(R.string.app_name),
content = getString(R.string.connection_runing_task_left, event.text)
)
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)
if(message.subscriptionId.isNotEmpty()){
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
AngConfigManager.removeInvalidServer(message.subscriptionId)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
AngConfigManager.sortByTestResultsForSub(message.subscriptionId)
}
}
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
onWorkerDone()
if (activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this)
@@ -141,4 +153,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
@@ -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
)
}
}
@@ -21,11 +21,15 @@ class TProxyService(
companion object {
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStartService(configPath: String, fd: Int)
private external fun TProxyStartService(configPath: String, fd: Int): Boolean
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStopService()
private external fun TProxyStopService(): Boolean
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyIsRunning(): Boolean
@JvmStatic
@Suppress("FunctionName")
@@ -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.entities.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()
}
}
@@ -1,216 +0,0 @@
package com.v2ray.ang.ui
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.v2ray.ang.R
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.CustomDividerItemDecoration
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
/**
* BaseActivity provides common helpers and UI wiring used across the app's activities.
*
* Responsibilities:
* - Inflate a shared base layout that contains a toolbar and a content container.
* - Provide convenient overloads of `setContentViewWithToolbar` to attach child layouts or
* view-binding roots into the base container and initialize the toolbar.
* - Expose a global in-layout `ProgressBar` (cached) with `showLoading()` / `hideLoading()` helpers.
* - Provide a helper to add a custom divider to RecyclerViews.
* - Wrap base context according to user locale settings.
*/
abstract class BaseActivity : AppCompatActivity() {
// Progress indicator that sits at the bottom of the toolbar
private var progressBar: LinearProgressIndicator? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (!Utils.getDarkModeStatus(this)) {
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = true
}
}
}
/**
* Handle action bar item selections.
*
* Currently this handles the home/up button by delegating to the activity's
* onBackPressedDispatcher to provide consistent back navigation behavior.
*
* @param item the selected menu item
* @return true if the event was handled, otherwise delegates to the superclass
*/
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
android.R.id.home -> {
// Handles the home button press by delegating to the onBackPressedDispatcher.
// This ensures consistent back navigation behavior.
onBackPressedDispatcher.onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
/**
* Wrap the base context with the user's locale settings.
*
* This ensures resources are loaded using the configured locale.
*
* @param newBase the original base context to wrap
*/
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
}
/**
* Adds a custom divider drawable to the provided RecyclerView.
*
* This is a convenience helper that constructs a [CustomDividerItemDecoration]
* using the given drawable resource id and adds it to the RecyclerView.
*
* @param recyclerView the target RecyclerView
* @param context the context used to resolve resources (may be activity or application context)
* @param drawableResId the drawable resource id to use as the divider
* @param orientation one of [DividerItemDecoration.VERTICAL] or [DividerItemDecoration.HORIZONTAL]
*
* @throws IllegalArgumentException if the drawable resource cannot be found
*/
protected fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, context: Context?, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(context!!, drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
// Create a DividerItemDecoration with the specified orientation
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
// Add the divider to the RecyclerView
recyclerView.addItemDecoration(dividerItemDecoration)
}
/**
* Configure the toolbar instance using the default toolbar id if null is passed.
*
* This helper will set the toolbar as the action bar and configure the up button
* visibility plus optional title.
*
* @param toolbar the toolbar instance to configure (may be null, in which case the view
* with id R.id.toolbar in the activity content will be used)
* @param showHomeAsUp whether the home/up affordance should be shown (default true)
* @param title optional title to set on the activity
*/
protected fun setupToolbar(toolbar: Toolbar?, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
val tb = toolbar ?: findViewById<Toolbar?>(R.id.toolbar)
tb?.let {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
title?.let { t -> this.title = t }
}
progressBar = findViewById(R.id.progress_bar)
}
/**
* Inflate the shared base layout, attach the child layout resource into the base
* content container, cache the in-layout ProgressBar and configure the toolbar.
*
* Typical usage in subclasses:
* setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = "Settings")
*
* @param layoutResId child layout resource to inflate into the base content container
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
* @param title optional activity title to set on the toolbar
*/
protected fun setContentViewWithToolbar(layoutResId: Int, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
val container = base.findViewById<FrameLayout>(R.id.content_container)
LayoutInflater.from(this).inflate(layoutResId, container, true)
progressBar = base.findViewById(R.id.progress_bar)
super.setContentView(base)
setupToolbar(base, showHomeAsUp, title)
}
/**
* Inflate the shared base layout, attach the provided child view (commonly a view-binding root)
* into the base content container, cache the in-layout ProgressBar and configure the toolbar.
*
* Typical usage with view binding:
* setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "...")
*
* @param childView the already-inflated child view to add to the base content container
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
* @param title optional activity title to set on the toolbar
*/
protected fun setContentViewWithToolbar(childView: View, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
val container = base.findViewById<FrameLayout>(R.id.content_container)
container.addView(childView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
progressBar = base.findViewById(R.id.progress_bar)
super.setContentView(base)
setupToolbar(base, showHomeAsUp, title)
}
/**
* Internal helper that configures the MaterialToolbar found in the inflated base root.
*
* @param baseRoot the root view of the inflated base layout
* @param showHomeAsUp whether to show the up/home affordance
* @param title optional title to set on the support action bar
*/
private fun setupToolbar(baseRoot: View, showHomeAsUp: Boolean, title: CharSequence?) {
val toolbar = baseRoot.findViewById<MaterialToolbar>(R.id.toolbar)
toolbar?.let {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
title?.let { t -> supportActionBar?.title = t }
}
}
/**
* Show the base layout's ProgressBar.
*
* This method is safe to call from background threads; the visibility change will
* be posted to the UI thread via [runOnUiThread]. If the base layout was not set yet
* (progressBar == null) the call is a no-op.
*/
protected fun showLoading() {
runOnUiThread {
progressBar?.visibility = View.VISIBLE
}
}
/**
* Hide the base layout's ProgressBar.
*
* Safe to call from background threads. No-op if the progress bar hasn't been cached.
*/
protected fun hideLoading() {
runOnUiThread {
progressBar?.visibility = View.GONE
}
}
/**
* Returns true when the base ProgressBar is currently visible.
*
* @return true if the progress bar exists and its visibility is VISIBLE
*/
protected fun isLoadingVisible(): Boolean {
return progressBar?.visibility == View.VISIBLE
}
}
@@ -1,53 +0,0 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.v2ray.ang.helper.CustomDividerItemDecoration
abstract class BaseFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null
protected val binding: VB
get() = _binding!!
protected abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = inflateBinding(inflater, container)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Adds a custom divider to a RecyclerView.
*
* @param recyclerView The target RecyclerView to which the divider will be added.
* @param drawableResId The resource ID of the drawable to be used as the divider.
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
*/
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
// Create a DividerItemDecoration with the specified orientation
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
// Add the divider to the RecyclerView
recyclerView.addItemDecoration(dividerItemDecoration)
}
}
@@ -1,79 +0,0 @@
package com.v2ray.ang.ui
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.UpdateCheckerManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
class CheckUpdateActivity : BaseActivity() {
private val binding by lazy { ActivityCheckUpdateBinding.inflate(layoutInflater) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.update_check_for_update))
binding.layoutCheckUpdate.setOnClickListener {
checkForUpdates(binding.checkPreRelease.isChecked)
}
binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
}
binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
checkForUpdates(binding.checkPreRelease.isChecked)
}
private fun checkForUpdates(includePreRelease: Boolean) {
toast(R.string.update_checking_for_update)
showLoading()
lifecycleScope.launch {
try {
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
if (result.hasUpdate) {
showUpdateDialog(result)
} else {
toastSuccess(R.string.update_already_latest_version)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
toastError(e.message ?: getString(R.string.toast_failure))
} finally {
hideLoading()
}
}
}
private fun showUpdateDialog(result: CheckUpdateResult) {
AlertDialog.Builder(this)
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
.setMessage(result.releaseNotes)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
@@ -1,22 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.v2ray.ang.dto.GroupMapItem
/**
* Pager adapter for subscription groups.
*/
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = groups.size
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
@SuppressLint("NotifyDataSetChanged")
fun update(groups: List<GroupMapItem>) {
this.groups = groups
notifyDataSetChanged()
}
}
@@ -1,325 +0,0 @@
package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
SwipeRefreshLayout.OnRefreshListener {
private val ownerActivity: MainActivity
get() = requireActivity() as MainActivity
private val mainViewModel: MainViewModel by activityViewModels()
private lateinit var adapter: MainRecyclerAdapter
private var itemTouchHelper: ItemTouchHelper? = null
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
private val share_method: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method_more)
}
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
companion object {
private const val ARG_SUB_ID = "subscriptionId"
fun newInstance(subId: String) = GroupServerFragment().apply {
arguments = Bundle().apply { putString(ARG_SUB_ID, subId) }
}
}
override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) =
FragmentGroupServerBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
} else {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
}
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
binding.refreshLayout.isEnabled = false
// binding.refreshLayout.setOnRefreshListener(this)
// // Set the distance to trigger sync to 160dp
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId != subId) {
return@observe
}
// LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index)
}
// LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
}
override fun onResume() {
super.onResume()
mainViewModel.subscriptionIdChanged(subId)
}
/**
* Shares server configuration
* Displays a dialog with sharing options and executes the selected action
* @param guid The server unique identifier
* @param profile The server configuration
* @param position The position in the list
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(ownerActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
if (share_method.isNotEmpty()) {
ivBinding.ivQcode.contentDescription = share_method[0]
} else {
ivBinding.ivQcode.contentDescription = "QR Code"
}
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
/**
* Shares full server configuration content to clipboard
* @param guid The server unique identifier
*/
private fun shareFullContent(guid: String) {
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val activityClass = when (profile.configType) {
EConfigType.CUSTOM -> ServerCustomConfigActivity::class.java
EConfigType.POLICYGROUP -> ServerGroupActivity::class.java
EConfigType.PROXYCHAIN -> ServerProxyChainActivity::class.java
else -> ServerActivity::class.java
}
val intent = Intent(ownerActivity, activityClass)
.putExtra("guid", guid)
.putExtra("isRunning", mainViewModel.isRunning.value)
.putExtra("createConfigType", profile.configType.value)
.putExtra("subscriptionId", subId)
launcher.launch(intent)
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid == MmkvManager.getSelectServer()) {
ownerActivity.toast(R.string.toast_action_not_allowed)
return
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
}
/**
* Executes the actual server removal process
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position)
}
/**
* Sets the selected server
* Updates UI and restarts service if needed
* @param guid The server unique identifier to select
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
val toPosition = mainViewModel.getPosition(guid)
adapter.setSelectServer(fromPosition, toPosition)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
}
private inner class ActivityAdapterListener : MainAdapterListener {
override fun onEdit(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
}
override fun onRemove(guid: String, position: Int) {
removeServer(guid, position)
}
override fun onEdit(guid: String, position: Int, profile: ProfileItem) {
editServer(guid, profile)
}
override fun onSelectServer(guid: String) {
setSelectServer(guid)
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType.isComplexType()
val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
options to if (isCustom) 2 else 0
} else {
val options = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
options to if (isCustom) 2 else 0
}
shareServer(guid, profile, position, shareOptions, skip)
}
}
override fun onRefresh() {
ownerActivity.importConfigViaSub()
//binding.refreshLayout.isRefreshing = false
}
/**
* Scrolls to the currently selected server in the RecyclerView
*/
fun scrollToSelectedServer() {
val selectedGuid = MmkvManager.getSelectServer()
if (selectedGuid.isNullOrEmpty()) {
ownerActivity.toast(R.string.title_file_chooser)
return
}
// Find the position of the selected server
val serversCache = mainViewModel.serversCache
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
val recyclerView = binding.recyclerView
if (position >= 0) {
// Get the layout manager
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
if (layoutManager != null) {
// Scroll to position with offset to center it on screen
// First scroll to position, then adjust to center
recyclerView.post {
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
}
} else {
// Fallback to smooth scroll if layout manager is not GridLayoutManager
recyclerView.smoothScrollToPosition(position)
}
} else {
ownerActivity.toast(R.string.toast_server_not_found_in_group)
}
}
}
@@ -1,169 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.LogcatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.core.content.FileProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
private val viewModel: LogcatViewModel by viewModels()
private lateinit var adapter: LogcatRecyclerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
binding.refreshLayout.setOnRefreshListener(this)
toast(getString(R.string.pull_down_to_refresh))
}
private fun onLogLongClick(log: String): Boolean {
Utils.setClipboard(this, log)
return true
}
private fun shareLogcat() {
lifecycleScope.launch(Dispatchers.IO) {
val logText = viewModel.getAll().joinToString("\n")
val result = try {
val shareDir = File(cacheDir, "shared_logs").apply {
mkdirs()
}
shareDir.listFiles()?.forEach { it.delete() }
val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
val logFile = File(shareDir, "v2rayNG_logcat_$timestamp.txt")
logFile.writeText(logText, Charsets.UTF_8)
val uri = FileProvider.getUriForFile(
this@LogcatActivity,
"${packageName}.cache",
logFile
)
uri to logFile.name
} catch (e: Exception) {
withContext(Dispatchers.Main) {
toast(e.localizedMessage ?: e.toString())
}
return@launch
}
withContext(Dispatchers.Main) {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, result.first)
putExtra(Intent.EXTRA_SUBJECT, result.second)
putExtra(Intent.EXTRA_TITLE, result.second)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
clipData = ClipData.newUri(contentResolver, result.second, result.first)
}
startActivity(
Intent.createChooser(
shareIntent,
getString(R.string.logcat_share)
)
)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_logcat, menu)
val searchItem = menu.findItem(R.id.search_view)
if (searchItem != null) {
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.filter(newText)
refreshData()
return false
}
})
searchView.setOnCloseListener {
viewModel.filter("")
refreshData()
false
}
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.copy_all -> {
val all = viewModel.getAll().joinToString("\n")
Utils.setClipboard(this, all)
toastSuccess(R.string.toast_success)
true
}
R.id.share_all -> {
shareLogcat()
true
}
R.id.clear_all -> {
lifecycleScope.launch(Dispatchers.IO) {
viewModel.clearLogcat()
withContext(Dispatchers.Main) {
refreshData()
}
}
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onRefresh() {
lifecycleScope.launch(Dispatchers.IO) {
viewModel.loadLogcat()
withContext(Dispatchers.Main) {
binding.refreshLayout.isRefreshing = false
refreshData()
}
}
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
adapter.notifyDataSetChanged()
}
}
@@ -1,52 +0,0 @@
package com.v2ray.ang.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.LogcatViewModel
class LogcatRecyclerAdapter(
private val viewModel: LogcatViewModel,
private val onLongClick: ((String) -> Boolean)? = null
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
try {
val logs = viewModel.getAll()
val log = logs[position]
if (log.isEmpty()) {
holder.itemSubSettingBinding.logTag.text = ""
holder.itemSubSettingBinding.logContent.text = ""
} else {
val content = log.split("):", limit = 2)
holder.itemSubSettingBinding.logTag.text = content.first().split("(", limit = 2).first().trim()
holder.itemSubSettingBinding.logContent.text = if (content.count() > 1) content.last().trim() else ""
}
holder.itemView.setOnLongClickListener {
onLongClick?.invoke(log) ?: false
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error binding log view data", e)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder(
ItemRecyclerLogcatBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
class MainViewHolder(val itemSubSettingBinding: ItemRecyclerLogcatBinding) : RecyclerView.ViewHolder(itemSubSettingBinding.root)
}
@@ -1,662 +0,0 @@
package com.v2ray.ang.ui
import android.content.Intent
import android.content.res.ColorStateList
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayoutMediator
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.databinding.ActivityMainBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
private val binding by lazy {
ActivityMainBinding.inflate(layoutInflater)
}
val mainViewModel: MainViewModel by viewModels()
private lateinit var groupPagerAdapter: GroupPagerAdapter
private var tabMediator: TabLayoutMediator? = null
private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
startV2Ray()
}
}
private val requestActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
restartV2Ray()
}
if (SettingsChangeManager.consumeSetupGroupTab()) {
setupGroupTab()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupToolbar(binding.toolbar, false, getString(R.string.title_server))
// setup viewpager and tablayout
groupPagerAdapter = GroupPagerAdapter(this, emptyList())
binding.viewPager.adapter = groupPagerAdapter
binding.viewPager.isUserInputEnabled = true
// setup navigation drawer
val toggle = ActionBarDrawerToggle(
this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
)
binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this)
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
})
binding.fab.setOnClickListener { handleFabAction() }
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
setupGroupTab()
setupViewModel()
SubscriptionUpdater.sync()
mainViewModel.reloadServerList()
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
}
}
private fun setupViewModel() {
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.isRunning.observe(this) { isRunning ->
applyRunningState(false, isRunning)
}
mainViewModel.startListenBroadcast()
mainViewModel.initAssets(assets)
}
private fun setupGroupTab() {
val groups = mainViewModel.getSubscriptions(this)
groupPagerAdapter.update(groups)
tabMediator?.detach()
tabMediator = TabLayoutMediator(binding.tabGroup, binding.viewPager) { tab, position ->
groupPagerAdapter.groups.getOrNull(position)?.let {
tab.text = it.remarks
tab.tag = it.id
}
}.also { it.attach() }
val targetIndex = groups.indexOfFirst { it.id == mainViewModel.subscriptionId }.takeIf { it >= 0 } ?: (groups.size - 1)
binding.viewPager.setCurrentItem(targetIndex, false)
binding.tabGroup.isVisible = groups.size > 1
}
private fun handleFabAction() {
applyRunningState(isLoading = true, isRunning = false)
if (mainViewModel.isRunning.value == true) {
CoreServiceManager.stopVService(this)
} else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this)
if (intent == null) {
startV2Ray()
} else {
requestVpnPermission.launch(intent)
}
} else {
startV2Ray()
}
}
private fun handleLayoutTestClick() {
if (mainViewModel.isRunning.value == true) {
setTestState(getString(R.string.connection_test_testing))
mainViewModel.testCurrentServerRealPing()
} else {
// service not running: keep existing no-op (could show a message if desired)
}
}
private fun startV2Ray() {
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
toast(R.string.title_file_chooser)
return
}
CoreServiceManager.startVService(this)
}
fun restartV2Ray() {
if (mainViewModel.isRunning.value == true) {
CoreServiceManager.stopVService(this)
}
lifecycleScope.launch {
delay(500)
startV2Ray()
}
}
private fun setTestState(content: String?) {
binding.tvTestState.text = content
}
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
if (isLoading) {
binding.fab.setImageResource(R.drawable.ic_fab_check)
return
}
if (isRunning) {
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
binding.layoutTest.isFocusable = false
}
}
override fun onResume() {
super.onResume()
}
override fun onPause() {
super.onPause()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, 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 {
mainViewModel.filterConfig(newText.orEmpty())
return false
}
})
searchView.setOnCloseListener {
mainViewModel.filterConfig("")
false
}
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.import_qrcode -> {
importQRcode()
true
}
R.id.import_clipboard -> {
importClipboard()
true
}
R.id.import_local -> {
importConfigLocal()
true
}
R.id.import_manually_policy_group -> {
importManually(EConfigType.POLICYGROUP.value)
true
}
R.id.import_manually_proxy_chain -> {
importManually(EConfigType.PROXYCHAIN.value)
true
}
R.id.import_manually_vmess -> {
importManually(EConfigType.VMESS.value)
true
}
R.id.import_manually_vless -> {
importManually(EConfigType.VLESS.value)
true
}
R.id.import_manually_ss -> {
importManually(EConfigType.SHADOWSOCKS.value)
true
}
R.id.import_manually_socks -> {
importManually(EConfigType.SOCKS.value)
true
}
R.id.import_manually_http -> {
importManually(EConfigType.HTTP.value)
true
}
R.id.import_manually_trojan -> {
importManually(EConfigType.TROJAN.value)
true
}
R.id.import_manually_wireguard -> {
importManually(EConfigType.WIREGUARD.value)
true
}
R.id.import_manually_hysteria2 -> {
importManually(EConfigType.HYSTERIA2.value)
true
}
R.id.export_all -> {
exportAll()
true
}
R.id.ping_all -> {
toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count()))
mainViewModel.testAllTcping()
true
}
R.id.real_ping_all -> {
toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count()))
mainViewModel.testAllRealPing()
true
}
R.id.service_restart -> {
restartV2Ray()
true
}
R.id.del_all_config -> {
delAllConfig()
true
}
R.id.del_duplicate_config -> {
delDuplicateConfig()
true
}
R.id.del_invalid_config -> {
delInvalidConfig()
true
}
R.id.sort_by_test_results -> {
sortByTestResults()
true
}
R.id.sub_update -> {
importConfigViaSub()
true
}
R.id.locate_selected_config -> {
locateSelectedServer()
true
}
else -> super.onOptionsItemSelected(item)
}
private fun importManually(createConfigType: Int) {
if (createConfigType == EConfigType.POLICYGROUP.value) {
startActivity(
Intent()
.putExtra("subscriptionId", mainViewModel.subscriptionId)
.setClass(this, ServerGroupActivity::class.java)
)
} else if (createConfigType == EConfigType.PROXYCHAIN.value) {
startActivity(
Intent()
.putExtra("subscriptionId", mainViewModel.subscriptionId)
.setClass(this, ServerProxyChainActivity::class.java)
)
} else {
startActivity(
Intent()
.putExtra("createConfigType", createConfigType)
.putExtra("subscriptionId", mainViewModel.subscriptionId)
.setClass(this, ServerActivity::class.java)
)
}
}
/**
* import config from qrcode
*/
private fun importQRcode(): Boolean {
launchQRCodeScanner { scanResult ->
if (scanResult != null) {
importBatchConfig(scanResult)
}
}
return true
}
/**
* import config from clipboard
*/
private fun importClipboard()
: Boolean {
try {
val clipboard = Utils.getClipboard(this)
importBatchConfig(clipboard)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to import config from clipboard", e)
return false
}
return true
}
private fun importBatchConfig(server: String?) {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
try {
val (count, countSub) = AngConfigManager.importBatchConfig(server, mainViewModel.subscriptionId, true)
delay(500L)
withContext(Dispatchers.Main) {
when {
count > 0 -> {
toast(getString(R.string.title_import_config_count, count))
mainViewModel.reloadServerList()
}
countSub > 0 -> setupGroupTab()
else -> toastError(R.string.toast_failure)
}
hideLoading()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
toastError(R.string.toast_failure)
hideLoading()
}
LogUtil.e(AppConfig.TAG, "Failed to import batch config", e)
}
}
}
/**
* import config from local config file
*/
private fun importConfigLocal(): Boolean {
try {
showFileChooser()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to import config from local file", e)
return false
}
return true
}
/**
* import config from sub
*/
fun importConfigViaSub(): Boolean {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val result = mainViewModel.updateConfigViaSubAll()
delay(500L)
launch(Dispatchers.Main) {
if (result.successCount + result.failureCount + result.skipCount == 0) {
toast(R.string.title_update_subscription_no_subscription)
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
toast(getString(R.string.title_update_config_count, result.configCount))
} else {
toast(
getString(
R.string.title_update_subscription_result,
result.configCount, result.successCount, result.failureCount, result.skipCount
)
)
}
if (result.configCount > 0) {
mainViewModel.reloadServerList()
}
hideLoading()
}
}
return true
}
private fun exportAll() {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.exportAllServer()
launch(Dispatchers.Main) {
if (ret > 0)
toast(getString(R.string.title_export_config_count, ret))
else
toastError(R.string.toast_failure)
hideLoading()
}
}
}
private fun delAllConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeAllServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_config_count, ret))
hideLoading()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
private fun delDuplicateConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeDuplicateServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_duplicate_config_count, ret))
hideLoading()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
private fun delInvalidConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeInvalidServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_config_count, ret))
hideLoading()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
private fun sortByTestResults() {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.sortByTestResults()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
hideLoading()
}
}
}
/**
* show file chooser
*/
private fun showFileChooser() {
launchFileChooser { uri ->
if (uri == null) {
return@launchFileChooser
}
readContentFromUri(uri)
}
}
/**
* read content from uri
*/
private fun readContentFromUri(uri: Uri) {
try {
contentResolver.openInputStream(uri).use { input ->
importBatchConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to read content from URI", e)
}
}
/**
* Locates and scrolls to the currently selected server.
* If the selected server is in a different group, automatically switches to that group first.
*/
private fun locateSelectedServer() {
val targetSubscriptionId = mainViewModel.findSubscriptionIdBySelect()
if (targetSubscriptionId.isNullOrEmpty()) {
toast(R.string.title_file_chooser)
return
}
val targetGroupIndex = groupPagerAdapter.groups.indexOfFirst { it.id == targetSubscriptionId }
if (targetGroupIndex < 0) {
toast(R.string.toast_server_not_found_in_group)
return
}
// Switch to target group if needed, then scroll to the server
if (binding.viewPager.currentItem != targetGroupIndex) {
binding.viewPager.setCurrentItem(targetGroupIndex, true)
binding.viewPager.postDelayed({ scrollToSelectedServer(targetGroupIndex) }, 1000)
} else {
scrollToSelectedServer(targetGroupIndex)
}
}
/**
* Scrolls to the selected server in the specified fragment.
* @param groupIndex The index of the group/fragment to scroll in
*/
private fun scrollToSelectedServer(groupIndex: Int) {
val itemId = groupPagerAdapter.getItemId(groupIndex)
val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment
if (fragment?.isAdded == true && fragment.view != null) {
fragment.scrollToSelectedServer()
} else {
toast(R.string.toast_fragment_not_available)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
moveTaskToBack(false)
return true
}
return super.onKeyDown(keyCode, event)
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
// Handle navigation view item clicks here.
when (item.itemId) {
R.id.sub_setting -> requestActivityLauncher.launch(Intent(this, SubSettingActivity::class.java))
R.id.per_app_proxy_settings -> requestActivityLauncher.launch(Intent(this, PerAppProxyActivity::class.java))
R.id.routing_setting -> requestActivityLauncher.launch(Intent(this, RoutingSettingActivity::class.java))
R.id.user_asset_setting -> requestActivityLauncher.launch(Intent(this, UserAssetActivity::class.java))
R.id.settings -> requestActivityLauncher.launch(Intent(this, SettingsActivity::class.java))
R.id.promotion -> Utils.openUri(this, "${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}")
R.id.logcat -> startActivity(Intent(this, LogcatActivity::class.java))
R.id.check_for_update -> startActivity(Intent(this, CheckUpdateActivity::class.java))
R.id.backup_restore -> requestActivityLauncher.launch(Intent(this, BackupActivity::class.java))
R.id.about -> startActivity(Intent(this, AboutActivity::class.java))
}
binding.drawerLayout.closeDrawer(GravityCompat.START)
return true
}
override fun onDestroy() {
tabMediator?.detach()
super.onDestroy()
}
}
@@ -1,233 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.viewmodel.MainViewModel
import java.util.Collections
class MainRecyclerAdapter(
private val mainViewModel: MainViewModel,
private val adapterListener: MainAdapterListener?
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
companion object {
private const val VIEW_TYPE_ITEM = 1
private const val VIEW_TYPE_FOOTER = 2
}
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private var data: MutableList<ServersCache> = mutableListOf()
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
data = newData?.toMutableList() ?: mutableListOf()
if (position >= 0 && position in data.indices) {
notifyItemChanged(position)
} else {
notifyDataSetChanged()
}
}
override fun getItemCount() = data.size + 1
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is MainViewHolder) {
val context = holder.itemMainBinding.root.context
val guid = data[position].guid
val profile = data[position].profile
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
//Name address
holder.itemMainBinding.tvName.text = profile.remarks
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
holder.itemMainBinding.tvType.text = getProtocolDescription(profile)
//TestResult
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
if ((aff?.testDelayMillis ?: 0L) < 0L) {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
} else {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
}
//layoutIndicator
if (guid == MmkvManager.getSelectServer()) {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorIndicator)
} else {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
}
//subscription remarks
val subRemarks = getSubscriptionRemarks(profile)
holder.itemMainBinding.tvSubscription.text = subRemarks
holder.itemMainBinding.layoutSubscription.visibility = if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
//layout
if (doubleColumnDisplay) {
holder.itemMainBinding.layoutShare.visibility = View.GONE
holder.itemMainBinding.layoutEdit.visibility = View.GONE
holder.itemMainBinding.layoutRemove.visibility = View.GONE
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
holder.itemMainBinding.layoutMore.setOnClickListener {
adapterListener?.onShare(guid, profile, position, true)
}
} else {
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
holder.itemMainBinding.layoutEdit.visibility = View.VISIBLE
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
holder.itemMainBinding.layoutMore.visibility = View.GONE
holder.itemMainBinding.layoutShare.setOnClickListener {
adapterListener?.onShare(guid, profile, position, false)
}
holder.itemMainBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(guid, position, profile)
}
holder.itemMainBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(guid, position)
}
}
holder.itemMainBinding.infoContainer.setOnClickListener {
adapterListener?.onSelectServer(guid)
}
}
}
/**
* Gets the server address information
* Hides part of IP or domain information for privacy protection
* @param profile The server configuration
* @return Formatted address string
*/
private fun getAddress(profile: ProfileItem): String {
return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
}
/**
* Gets the subscription remarks information
* @param profile The server configuration
* @return Subscription remarks string, or empty string if none
*/
private fun getSubscriptionRemarks(profile: ProfileItem): String {
val subRemarks =
if (mainViewModel.subscriptionId.isEmpty())
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
else
null
return subRemarks?.toString() ?: ""
}
private fun getProtocolDescription(profile: ProfileItem): String {
if (profile.configType.isComplexType()) {
return profile.configType.name
}
val parts = mutableListOf<String>()
parts.add(profile.configType.name)
// Transport: hide tcp or blank
profile.network?.let { net ->
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) {
parts.add(net)
}
}
// Security: hide blank or tls
profile.security?.let { sec ->
if (sec.isNotBlank() && !sec.equals("tls", ignoreCase = true)) {
parts.add(sec)
}
}
return parts.joinToString(" / ")
}
fun removeServerSub(guid: String, position: Int) {
val idx = data.indexOfFirst { it.guid == guid }
if (idx >= 0) {
data.removeAt(idx)
notifyItemRemoved(idx)
notifyItemRangeChanged(idx, data.size - idx)
}
}
fun setSelectServer(fromPosition: Int, toPosition: Int) {
notifyItemChanged(fromPosition)
notifyItemChanged(toPosition)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
return when (viewType) {
VIEW_TYPE_ITEM ->
MainViewHolder(ItemRecyclerMainBinding.inflate(LayoutInflater.from(parent.context), parent, false))
else ->
FooterViewHolder(ItemRecyclerFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
}
override fun getItemViewType(position: Int): Int {
return if (position == data.size) {
VIEW_TYPE_FOOTER
} else {
VIEW_TYPE_ITEM
}
}
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun onItemSelected() {
itemView.setBackgroundColor(Color.LTGRAY)
}
fun onItemClear() {
itemView.setBackgroundColor(0)
}
}
class MainViewHolder(val itemMainBinding: ItemRecyclerMainBinding) :
BaseViewHolder(itemMainBinding.root), ItemTouchHelperViewHolder
class FooterViewHolder(val itemFooterBinding: ItemRecyclerFooterBinding) :
BaseViewHolder(itemFooterBinding.root)
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
mainViewModel.swapServer(fromPosition, toPosition)
if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition)
}
notifyItemMoved(fromPosition, toPosition)
return true
}
override fun onItemMoveCompleted() {
// do nothing
}
override fun onItemDismiss(position: Int) {
}
}
@@ -1,322 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBypassListBinding
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.extension.v2RayApplication
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
import es.dmoral.toasty.Toasty
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
class PerAppProxyActivity : BaseActivity() {
private val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) }
private var adapter: PerAppProxyAdapter? = null
private var appsAll: List<AppInfo>? = null
private val viewModel: PerAppProxyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.per_app_proxy_settings))
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
initList()
binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, isChecked)
}
binding.switchPerAppProxy.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
binding.switchBypassApps.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, isChecked)
}
binding.switchBypassApps.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
binding.layoutSwitchBypassAppsTips.setOnClickListener {
Toasty.info(this, R.string.summary_pref_per_app_proxy, Toast.LENGTH_LONG, true).show()
}
}
private fun initList() {
showLoading()
lifecycleScope.launch {
try {
val apps = withContext(Dispatchers.IO) {
val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
val blacklistSet = viewModel.getAll()
if (blacklistSet.isNotEmpty()) {
appsList.forEach { app ->
app.isSelected = if (blacklistSet.contains(app.packageName)) 1 else 0
}
appsList.sortedWith { p1, p2 ->
when {
p1.isSelected > p2.isSelected -> -1
p1.isSelected < p2.isSelected -> 1
p1.isSystemApp > p2.isSystemApp -> 1
p1.isSystemApp < p2.isSystemApp -> -1
p1.appName.lowercase() > p2.appName.lowercase() -> 1
p1.appName.lowercase() < p2.appName.lowercase() -> -1
p1.packageName > p2.packageName -> 1
p1.packageName < p2.packageName -> -1
else -> 0
}
}
} else {
val collator = Collator.getInstance()
appsList.sortedWith(compareBy(collator) { it.appName })
}
}
appsAll = apps
adapter = PerAppProxyAdapter(apps, viewModel)
binding.recyclerView.adapter = adapter
} catch (e: Exception) {
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
} finally {
hideLoading()
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_bypass_list, 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 {
filterProxyApp(newText.orEmpty())
return false
}
})
}
return super.onCreateOptionsMenu(menu)
}
@SuppressLint("NotifyDataSetChanged")
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.select_all -> {
selectAllApp()
allowPerAppProxy()
true
}
R.id.invert_selection -> {
invertSelection()
allowPerAppProxy()
true
}
R.id.select_proxy_app -> {
selectProxyAppAuto()
allowPerAppProxy()
true
}
R.id.import_proxy_app -> {
importProxyApp()
allowPerAppProxy()
true
}
R.id.export_proxy_app -> {
exportProxyApp()
true
}
else -> super.onOptionsItemSelected(item)
}
private fun selectAllApp() {
adapter?.let { adapter ->
val pkgNames = adapter.apps.map { it.packageName }
val allSelected = pkgNames.all { viewModel.contains(it) }
if (allSelected) {
viewModel.removeAll(pkgNames)
} else {
viewModel.addAll(pkgNames)
}
refreshData()
}
}
private fun invertSelection() {
adapter?.let { adapter ->
adapter.apps.forEach { app ->
viewModel.toggle(app.packageName)
}
refreshData()
}
}
private fun selectProxyAppAuto() {
toast(R.string.msg_downloading_content)
showLoading()
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
lifecycleScope.launch(Dispatchers.IO) {
var content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000
)
)
if (content.isNullOrEmpty()) {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
) ?: ""
}
launch(Dispatchers.Main) {
//LogUtil.i(AppConfig.TAG, content)
selectProxyApp(content, true)
toastSuccess(R.string.toast_success)
hideLoading()
}
}
}
private fun importProxyApp() {
val content = Utils.getClipboard(applicationContext)
if (TextUtils.isEmpty(content)) return
selectProxyApp(content, false)
toastSuccess(R.string.toast_success)
}
private fun exportProxyApp() {
var lst = binding.switchBypassApps.isChecked.toString()
viewModel.getAll().forEach { pkg ->
lst = lst + System.lineSeparator() + pkg
}
Utils.setClipboard(applicationContext, lst)
toastSuccess(R.string.toast_success)
}
private fun allowPerAppProxy() {
binding.switchPerAppProxy.isChecked = true
SettingsChangeManager.makeRestartService()
}
@SuppressLint("NotifyDataSetChanged")
private fun selectProxyApp(content: String, force: Boolean): Boolean {
try {
val proxyApps = if (TextUtils.isEmpty(content)) {
Utils.readTextFromAssets(v2RayApplication, "proxy_package_name")
} else {
content
}
if (TextUtils.isEmpty(proxyApps)) return false
viewModel.clear()
if (binding.switchBypassApps.isChecked) {
adapter?.let { adapter ->
adapter.apps.forEach { app ->
val packageName = app.packageName
if (!inProxyApps(proxyApps, packageName, force)) {
viewModel.add(packageName)
}
}
refreshData()
}
} else {
adapter?.let { adapter ->
adapter.apps.forEach { app ->
val packageName = app.packageName
if (inProxyApps(proxyApps, packageName, force)) {
viewModel.add(packageName)
}
}
refreshData()
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
return false
}
return true
}
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
println(packageName)
if (force) {
if (packageName == "com.google.android.webview") return false
if (packageName.startsWith("com.google")) return true
}
return proxyApps.indexOf(packageName) >= 0
}
private fun filterProxyApp(content: String): Boolean {
val apps = ArrayList<AppInfo>()
val key = content.uppercase()
if (key.isNotEmpty()) {
appsAll?.forEach {
if (it.appName.uppercase().indexOf(key) >= 0
|| it.packageName.uppercase().indexOf(key) >= 0
) {
apps.add(it)
}
}
} else {
appsAll?.forEach {
apps.add(it)
}
}
adapter = PerAppProxyAdapter(apps, adapter?.viewModel ?: viewModel)
binding.recyclerView.adapter = adapter
refreshData()
return true
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
adapter?.notifyDataSetChanged()
}
}
@@ -1,77 +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
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(
val apps: List<AppInfo>,
val viewModel: PerAppProxyViewModel
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object {
private const val VIEW_TYPE_HEADER = 0
private const val VIEW_TYPE_ITEM = 1
}
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is AppViewHolder) {
val appInfo = apps[position - 1]
holder.bind(appInfo)
}
}
override fun getItemCount() = apps.size + 1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
val ctx = parent.context
return when (viewType) {
VIEW_TYPE_HEADER -> {
val view = View(ctx)
view.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0
)
BaseViewHolder(view)
}
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
}
}
override fun getItemViewType(position: Int) = if (position == 0) VIEW_TYPE_HEADER else VIEW_TYPE_ITEM
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
View.OnClickListener {
private lateinit var appInfo: AppInfo
fun bind(appInfo: AppInfo) {
this.appInfo = appInfo
itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
itemBypassBinding.name.text = if (appInfo.isSystemApp) {
String.format("** %s", appInfo.appName)
} else {
appInfo.appName
}
itemBypassBinding.packageName.text = appInfo.packageName
itemBypassBinding.checkBox.isChecked = viewModel.contains(appInfo.packageName)
itemView.setOnClickListener(this)
}
override fun onClick(v: View?) {
val packageName = appInfo.packageName
viewModel.toggle(packageName)
itemBypassBinding.checkBox.isChecked = viewModel.contains(packageName)
}
}
}
@@ -1,190 +0,0 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RoutingEditActivity : BaseActivity() {
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
private val position by lazy { intent.getIntExtra("position", -1) }
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
setupOutboundTagInput()
setupProcessPicker()
val rulesetItem = SettingsManager.getRoutingRuleset(position)
if (rulesetItem != null) {
bindingServer(rulesetItem)
} else {
clearServer()
}
SettingsManager.canUseProcessRouting().let { canUse ->
binding.etProcess.isEnabled = canUse
binding.btnProcessPicker.isEnabled = canUse
}
}
private fun setupProcessPicker() {
binding.btnProcessPicker.setOnClickListener {
processPickerLauncher.launch(
AppPickerActivity.createIntent(
context = this,
selectedPackages = getSelectedProcessPackages(),
title = getString(R.string.routing_settings_process)
)
)
}
}
private fun getSelectedProcessPackages(): List<String> {
return binding.etProcess.text
.toString()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
}
/**
* Sets up the AutoCompleteTextView for outbound tag:
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
* The dropdown button triggers showing the full list without typing.
*/
private fun setupOutboundTagInput() {
val profileRemarks = SettingsManager.getProfileRemarks()
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
binding.spOutboundTag.setAdapter(adapter)
// threshold=0 means show all suggestions even before typing; still need focus+request
binding.spOutboundTag.threshold = 0
// Dropdown arrow button shows the full suggestion list
binding.btnOutboundTagDropdown.setOnClickListener {
binding.spOutboundTag.requestFocus()
binding.spOutboundTag.showDropDown()
}
// Also show on field click when it already has focus
binding.spOutboundTag.setOnClickListener {
binding.spOutboundTag.showDropDown()
}
}
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks)
binding.chkLocked.isChecked = rulesetItem.locked == true
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
binding.etProcess.text = Utils.getEditable(rulesetItem.process?.joinToString(","))
binding.etPort.text = Utils.getEditable(rulesetItem.port)
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
// Set text directly; filter won't fire because we're not using setText(filter=true)
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
return true
}
private fun clearServer(): Boolean {
binding.etRemarks.text = null
binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
return true
}
private fun saveServer(): Boolean {
val rulesetItem = SettingsManager.getRoutingRuleset(position) ?: RulesetItem()
rulesetItem.apply {
remarks = binding.etRemarks.text.toString()
locked = binding.chkLocked.isChecked
domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
process = binding.etProcess.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
port = binding.etPort.text.toString().nullIfBlank()
network = binding.etNetwork.text.toString().nullIfBlank()
outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY }
}
if (rulesetItem.remarks.isNullOrEmpty()) {
toast(R.string.sub_setting_remarks)
return false
}
SettingsManager.saveRoutingRuleset(position, rulesetItem)
toastSuccess(R.string.toast_success)
finish()
return true
}
private fun deleteServer(): Boolean {
if (position >= 0) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
SettingsManager.removeRoutingRuleset(position)
launch(Dispatchers.Main) {
finish()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
}
return true
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delConfig = menu.findItem(R.id.del_config)
if (position < 0) {
delConfig?.isVisible = false
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
}
R.id.save_config -> {
saveServer()
true
}
else -> super.onOptionsItemSelected(item)
}
}
@@ -1,217 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RoutingSettingActivity : HelperBaseActivity() {
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
private val ownerActivity: RoutingSettingActivity
get() = this
private val viewModel: RoutingSettingsViewModel by viewModels()
private lateinit var adapter: RoutingSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null
private val routing_domain_strategy: Array<out String> by lazy {
resources.getStringArray(R.array.routing_domain_strategy)
}
private val preset_rulesets: Array<out String> by lazy {
resources.getStringArray(R.array.preset_rulesets)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
binding.tvDomainStrategySummary.text = getDomainStrategy()
binding.layoutDomainStrategy.setOnClickListener {
setDomainStrategy()
}
}
override fun onResume() {
super.onResume()
refreshData()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_routing_setting, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
R.id.import_predefined_rulesets -> importPredefined().let { true }
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
R.id.import_rulesets_from_qrcode -> importQRcode()
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
else -> super.onOptionsItemSelected(item)
}
private fun getDomainStrategy(): String {
return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: routing_domain_strategy.first()
}
private fun setDomainStrategy() {
android.app.AlertDialog.Builder(this).setItems(routing_domain_strategy.asList().toTypedArray()) { _, i ->
try {
val value = routing_domain_strategy[i]
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
binding.tvDomainStrategySummary.text = value
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to set domain strategy", e)
}
}.show()
}
private fun importPredefined() {
AlertDialog.Builder(this).setItems(preset_rulesets.asList().toTypedArray()) { _, i ->
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
try {
lifecycleScope.launch(Dispatchers.IO) {
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, i)
launch(Dispatchers.Main) {
refreshData()
toastSuccess(R.string.toast_success)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do nothing
}
.show()
}.show()
}
private fun importFromClipboard() {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
val clipboard = try {
Utils.getClipboard(this)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get clipboard content", e)
toastError(R.string.toast_failure)
return@setPositiveButton
}
lifecycleScope.launch(Dispatchers.IO) {
val result = SettingsManager.resetRoutingRulesets(clipboard)
withContext(Dispatchers.Main) {
if (result) {
refreshData()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do nothing
}
.show()
}
private fun importQRcode(): Boolean {
launchQRCodeScanner { scanResult ->
if (scanResult != null) {
importRulesetsFromQRcode(scanResult)
}
}
return true
}
private fun export2Clipboard() {
val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) {
toastError(R.string.toast_failure)
} else {
Utils.setClipboard(this, JsonUtil.toJson(rulesetList))
toastSuccess(R.string.toast_success)
}
}
private fun importRulesetsFromQRcode(qrcode: String?): Boolean {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
val result = SettingsManager.resetRoutingRulesets(qrcode)
withContext(Dispatchers.Main) {
if (result) {
refreshData()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do nothing
}
.show()
return true
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
viewModel.reload()
adapter.notifyDataSetChanged()
}
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
}
override fun onRemove(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
refreshData()
}
}
}
@@ -1,80 +0,0 @@
package com.v2ray.ang.ui
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
class RoutingSettingRecyclerAdapter(
private val viewModel: RoutingSettingsViewModel,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
ItemTouchHelperAdapter {
override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
val rulesets = viewModel.getAll()
val ruleset = rulesets[position]
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.process ?: ruleset.port)?.toString()
holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit("", position)
}
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
if (!it.isPressed) return@setOnCheckedChangeListener
ruleset.enabled = isChecked
viewModel.update(position, ruleset)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder(
ItemRecyclerRoutingSettingBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
class MainViewHolder(val itemRoutingSettingBinding: ItemRecyclerRoutingSettingBinding) :
BaseViewHolder(itemRoutingSettingBinding.root), ItemTouchHelperViewHolder
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun onItemSelected() {
itemView.setBackgroundColor(Color.LTGRAY)
}
fun onItemClear() {
itemView.setBackgroundColor(0)
}
}
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
viewModel.swap(fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
return true
}
override fun onItemMoveCompleted() {
adapterListener?.onRefreshData()
}
override fun onItemDismiss(position: Int) {
}
}
@@ -1,20 +0,0 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
class ScStartActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (!CoreServiceManager.isRunning()) {
CoreServiceManager.startVServiceFromToggle(this)
}
finish()
}
}
@@ -1,19 +0,0 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
class ScStopActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(this)
}
finish()
}
}
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more