Compare commits

...
322 Commits
Author SHA1 Message Date
2dust aa4b05503a up 2.3.14 2026-10-28 10:40:04 +08:00
Yuriy Tumanov 7179f0838f Use the default port for Hysteria2 links without a port (#6358) 2026-10-28 10:12:18 +08:00
Miheichev Aleksandr Sergeevich 0d1c57000f build(deps): bump OkHttp from 5.5.0 to 5.5.1 (#6351) 2026-10-28 10:11:05 +08:00
Figim 40b8706ccd Update subscription page string in Azerbaijani (#6349)
Edit the translation added yesterday.
2026-10-28 10:10:50 +08:00
Miheichev Aleksandr Sergeevich 0f961c54c8 fix(deps): update hev-socks5-tunnel to 2.19.3 (#6357)
* chore(deps): update hev-socks5-tunnel to 2.19.2

* fix(root): disable jumbo for LAN forwarding

* fix(root): guard IPv6 LAN TSO fallback

* chore(deps): update hev-socks5-tunnel to 2.19.3
2026-10-25 18:49:47 +08:00
2dust 1b78a510f2 Revert "chore(deps): update hev-socks5-tunnel to 2.19.1 (#6328)"
This reverts commit 552cc2425d.
2026-10-24 18:45:21 +08:00
2dust 01b4630fdd up 2.3.13 2026-10-22 10:16:56 +08:00
Miheichev Aleksandr Sergeevich 6214bc19d4 ci: update actions/setup-java to v6.1.0 (#6327) 2026-10-22 10:00:24 +08:00
rasa68andrasa68 b8d13bf52c Show subscription usage info box (traffic, expiry, support link) on subscription groups (#6333)
* Show subscription usage info box on subscription groups

* Float the info box over the server list and make it translucent

---------

Co-authored-by: rasa68 <noreply@example.com>
2026-10-22 09:59:00 +08:00
Miheichev Aleksandr Sergeevich 3165bcf4fe chore(build): update Gradle wrapper to 9.8.2 (#6343) 2026-10-22 09:48:18 +08:00
rasa68andrasa68 eedb287cc9 Redesign subscription group tabs as a floating pill bar with count badges (#6336)
* Redesign subscription group tabs as a floating pill bar with count badges

* Group tabs: dark-mode bar tone, equal-width tabs when they fit, room around selected tab

---------

Co-authored-by: rasa68 <noreply@example.com>
2026-10-22 09:48:09 +08:00
Figim 59ff87606f Fix small symbol in Azerbaijani localization (#6341)
* Fix small symbol

* Fix space
2026-10-22 09:39:52 +08:00
dependabot[bot] 700740a187 Bump actions/setup-java from 6.0.1 to 6.1.0 (#6334)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 6.0.1 to 6.1.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v6.0.1...v6.1.0)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.1.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-10-22 09:38:56 +08:00
fanymagnetandfanymagnet bb59a71cb9 fix(subscription): refresh main list after updating subscriptions (#6329)
Updating subscriptions from the subscription screen without auto-test replaced
profile GUIDs but did not request a main list refresh. The main screen kept stale
rows, and editing one opened an empty editor because its payloads had been removed.

Co-authored-by: fanymagnet <>
2026-10-22 09:38:35 +08:00
Miheichev Aleksandr Sergeevich 552cc2425d chore(deps): update hev-socks5-tunnel to 2.19.1 (#6328)
* chore(deps): update hev-socks5-tunnel to 2.19.0

* chore(deps): update hev-socks5-tunnel to 107a508
2026-10-22 09:38:02 +08:00
2dust c8f373bd3f Update AndroidLibXrayLite 2026-10-22 09:36:56 +08:00
2dust 118cf5bc7d up 2.3.12 2026-10-18 16:37:41 +08:00
2dust 58ca4cfe23 Update AndroidLibXrayLite 2026-10-18 16:32:00 +08:00
2dust e1b73f2fa8 up 2.3.11 2026-10-11 15:00:39 +08:00
Miheichev Aleksandr Sergeevich 143ab036bf chore(build): update Gradle wrapper to 9.8.1 and Kotlin to 2.4.21 (#6301)
Gradle 9.8.1 fixes three high-severity advisories that affect Gradle
9.0.0 to 9.8.0 (GHSA-mvvg-497x-hmj8, GHSA-xwqc-3h47-hg64,
GHSA-j5m7-59rp-24f5). Kotlin 2.4.21 is a bug-fix release for 2.4.20;
the app's Kotlin compiler output is byte-identical to the 2.4.20 build.
2026-10-11 14:26:09 +08:00
2dust a258ba8bc1 Update AndroidLibXrayLite 2026-10-11 14:21:26 +08:00
Figimand2dust e1b190d816 Azerbaijani localization support (#6302)
* Azerbaijani localization support

Azerbaijani localization support

* Add Azerbaijani language support in arrays.xml

* Fix

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-10-11 14:17:38 +08:00
Ray 4c9c9afdc9 fix(root): DNAT DNS queries from LAN resolvers to public IP (#6298)
In root mode, DNS (port 53) is marked into the tun and reaches the
core's SOCKS inbound as a UDP relay to the original resolver address
(e.g. 192.168.1.1:53). The core's routing rule
{inboundTag=socks, port=53, outboundTag=dns-out} should intercept it
and resolve via the configured remote DNS (e.g. Cloudflare DoH).

However, when the system DNS server is a private/LAN IP and the user
has a geoip:private → direct routing rule, that rule matches the
destination IP (192.168.1.1) and sends the DNS query straight to the
LAN resolver (ISP DNS) — bypassing dns-out entirely. This causes:
  - DNS poisoning for domains the ISP blocks/poisons
  - CDN mis-resolution (wrong POP)
  - Intermittent 'some sites work, some don't' behavior

Fix: add iptables nat OUTPUT DNAT to rewrite DNS queries destined for
private/LAN CIDRs to the configured remote DNS server (or 1.1.1.1
fallback), mirroring the existing tethered-client DNAT in
buildLanShareSetup. This prevents geoip:private from matching, so
dns-out can resolve via the proxy.

Verified on OnePlus 13R (OxygenOS 16, WildKernel v2.2.0-r4):
  - Before: youtube.com → 185.45.5.35 (fake ISP IP), timeout
  - After DNAT to 1.1.1.1: youtube.com → 142.251.45.14 (real), 301
  - tcpdump confirms response source changes from 192.168.1.1 to
    correct resolution through the proxy
2026-10-11 14:04:49 +08:00
Eliot the Cougar 6fe3893bfd Fix policy group fallback isolation (#6283)
Give least-ping and least-load groups their own first member as fallback so missing or failed probes cannot send a matched routing rule through the main profile. Keep least-load on its burst observatory and preserve random and round-robin fallback behavior. Cover group isolation and observatory selection with regression tests.
2026-10-06 15:32:19 +08:00
Miheichev Aleksandr Sergeevich 61c4e40ec1 test(root): fix flaky RootProcessRunner inherited-stdout test (#6282)
The test gave RootProcessRunner.run a 250 ms deadline. When sh takes
longer than that to start and finish, the runner hits the deadline,
destroys it and returns -1, and the test fails with
"expected: <0> but was: <-1>". With the sh from Git for Windows this
happened in 6 of 30 runs.

The test's real guard is the 1500 ms elapsed bound against the
background "sleep 5". A 3000 ms deadline leaves that bound alone to
decide the result, so a runner that keeps a finished command open until
its deadline now fails the test as well; with 250 ms it passed.
2026-10-06 15:29:09 +08:00
2dust 4a1510eb65 Refresh switch widget styling and icons 2026-10-06 15:23:04 +08:00
2dust 44a56f56bc Revert "Feat: Azerbaijani localization support and language selection (#6286)"
This reverts commit 3687436792.
2026-10-06 14:56:50 +08:00
Figim 3687436792 Feat: Azerbaijani localization support and language selection (#6286)
* Azerbaijani localization

Azerbaijani language support added

* Delete V2rayNG/app/src/main/res/values-az

* Azerbaijani localization support

Azerbaijani translations are now available

* Fix

* Add Azerbaijani language selection

* Update Azerbaijani strings in strings.xml

* Fix string formatting in Azerbaijani resource file
2026-10-06 14:48:39 +08:00
cin ffc9614417 feat(wireguard): add features from the new core version (#6290)
* feat(wireguard): add features from the new core version

- The `local` value is no longer allowed for `remoteDNS`. If this value is passed in the profile, the core will panic while calling `netip.MustParseAddr`.

- Add the `targetStrategy` field to `OutboundBean`.

- The `domainStrategy` field has been removed from `OutSettingsBean`. Any value passed for `domainStrategy` is now ignored.

* update locales

- ru,en - handwritten.
- other - AI-generated.
2026-10-06 14:43:02 +08:00
JaroWorldand2dust fcacb9cfb0 Keep fractional numbers when saving JSON array subscriptions (#6293)
* Keep fractional numbers when saving JSON array subscriptions

toJsonPretty wrote every Double through toInt(), so 0.4 became 0 and
values above Int.MAX_VALUE were clamped. Write whole numbers as Long
and leave other values as Double.

Fixes #6291

* Add a JsonUtil test for numbers in config arrays

* Update JsonUtil.kt

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-10-06 14:41:29 +08:00
2dust a4dd3d08de up 2.3.10 2026-10-01 17:00:28 +08:00
2dust ab09f816a0 Update libs.versions.toml 2026-10-01 16:17:33 +08:00
2dust 504f22d75b Fix server row text overlap in main pager 2026-10-01 16:13:46 +08:00
2dust 996b039b48 Update AndroidLibXrayLite 2026-10-01 15:58:47 +08:00
Miheichev Aleksandr SergeevichandClaude Opus 5.5 e151890c55 ci: update Android NDK to r30 (30.0.16248370) (#6275)
r30 is the current NDK LTS; r29 is no longer supported. The NDK path
is part of the libhevtun cache key, so the cached libraries are rebuilt
with the new toolchain.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:27:02 +08:00
Miheichev Aleksandr SergeevichandClaude Opus 5.5 04d407f5a1 chore(deps): update hev-socks5-tunnel to 2.18.0 (#6272)
64cc609 (2.16.0+24) -> d9dca26 (tag 2.18.0), 16 commits. Changes on
the paths the app uses:

- HevSocks5SessionUDP: skip datagrams without an address or with an
  empty payload, and set the IPv4 address type for a fixed UDP peer
  (c02e3fd)
- lwIP 2a11c14 -> e22c9d2: fix PRETEND handling of IPv6 extension
  headers and fragments, validate UDP PRETEND packets before accepting
  new sessions
- HevSocks5Tunnel: assert on the stop event write result (c362232)

The rest is tunnel code for Windows, macOS, FreeBSD and NetBSD, an
optional Windows-only tunnel.guid key, mapped DNS, the upstream AAR
build, docs and version bumps. JNI entry points, Android.mk/build.mk
and the config keys the app writes are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:25:50 +08:00
Miheichev Aleksandr SergeevichandClaude Opus 5.5 5029d75475 test: migrate unit tests from JUnit 4 to JUnit 6 (#6276)
JUnit 4 is in maintenance mode, and 4.13.2 (February 2021) is its last
release. Move the unit tests to JUnit Jupiter 6.1.3: the JUnit BOM,
junit-jupiter and the Platform launcher replace junit:junit, and the
unit test tasks run on the JUnit Platform. The test sources change only
mechanically: imports, lifecycle annotations, TemporaryFolder to
@TempDir, and the message argument moved last in three assertions.

Jupiter skips an invalid @Test method, such as one that returns a
value, with only a warning, where JUnit 4 failed the run. Treat
discovery warnings as failures to keep that behavior.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:20:41 +08:00
Miheichev Aleksandr SergeevichandClaude Opus 5.5 85b1f694c9 chore(build): update Gradle wrapper to 9.8.0 (#6274)
AGP 9.4.1 is in the AGP range Gradle 9.8.0 was tested with (9.0 to
9.5.0-alpha02). AGP 9.4 requires at least Gradle 9.6.0.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:19:26 +08:00
Miheichev Aleksandr SergeevichandClaude Opus 5.5 c63e18b751 chore(deps): update mockito-kotlin to 6.4.0, drop mockito-inline (#6273)
mockito-kotlin shared the mockitoMockitoInline version key with
mockito-inline, which was discontinued after 5.2.0 because Mockito 5
made the inline mock maker the default in mockito-core. The shared key
kept mockito-kotlin on 5.2.0.

- org.mockito:mockito-inline 5.2.0 -> org.mockito:mockito-core 5.24.0
- org.mockito.kotlin:mockito-kotlin 5.2.0 -> 6.4.0, on its own key

On the test classpath mockito-core moves from 5.7.0 to 5.24.0 and
Byte Buddy from 1.14.9 to 1.17.7, so the unit tests also run on JDK 25.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:17:16 +08:00
Eliot the Cougar 89f53233cb Fix navigation bar theme API lint errors (#6259) 2026-10-01 15:08:56 +08:00
Hossein Abaspanahandeliotcougar be1f2cf00b Update Luri Bakhtiari translation (#6169)
* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Fixed reported issue #1

* Reported issue 3, section 2, fixed

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

Co-authored-by: eliotcougar <eliotfur@gmail.com>

---------

Co-authored-by: eliotcougar <eliotfur@gmail.com>
2026-10-01 15:04:14 +08:00
2dust bd21bbc3f9 up 2.3.9 2026-09-22 14:49:01 +08:00
2dust 6e5d742e40 Fix
https://github.com/2dust/v2rayNG/issues/6240
2026-09-22 14:48:05 +08:00
2dust 4472162614 Preserve groups when replacing profiles 2026-09-22 14:30:22 +08:00
2dust 1f7cef32dc Animate start FAB icon spin on tap 2026-09-22 11:11:59 +08:00
2dust 7e68e2eaad Adjust toast background opacity
Increase toast background opacity from 70% to 85% for all theme variants in Theme.kt. This improves readability and keeps toast notifications visually consistent without changing their content or behavior.
2026-09-22 10:42:01 +08:00
2dust 4c7c964e65 Show service start failure details
Pass the optional failure message from the service layer through `MainServiceEvent` and show it in `MainViewModel` when available. This preserves the existing generic error toast as a fallback while surfacing more specific startup failures to the user.
2026-09-22 10:41:31 +08:00
2dust 1bd1bceadb Delete ServerActivity.kt 2026-09-21 19:13:13 +08:00
2dust 0e80f6b431 Add snackbar-safe toast dispatch
Refactor toast helpers to route through a shared dispatcher that first tries the app snackbar manager and falls back to Android Toasts on the main thread. This adds a `long` duration option across normal/success/error/info toast APIs and avoids posting to a new Handler for every message by reusing a single main-thread handler.
2026-09-21 19:10:33 +08:00
2dust 1ad5f30ede Show inline validation errors in forms
Move several save-time validation failures from toast messages to field error states in the server and subscription editors. Password validation now marks the relevant server form field, and subscription editing highlights invalid URLs and update intervals before save instead of relying on transient toasts.
2026-09-21 19:10:10 +08:00
2dust e4c0b09e1b Show inline errors in Compose forms
Replace toast-only required-field validation with Compose text field error states across routing, server, subscription, and user asset editors. The shared form field now supports `isError` and supporting text so save actions can block invalid input and highlight missing remarks, address, port, and URL values directly in the form.
2026-09-21 17:00:54 +08:00
Eliot the Cougar 073376e46d Localize notification-channel names (#6195)
Replace hard-coded channel labels with dedicated resource names for the main service, subscription updates, and connection checks in all nine supported locales. Resolve names through AppLocaleManager for service and application contexts.

Reuse the existing channel when refreshing its localized name, preserving channel IDs and user-controlled importance, sound, vibration, and visibility. Apply initial defaults only to newly created channels and remove the obsolete hard-coded service-channel name.

Extract channel localization from local/talkback-all-10 without adding its transient-message channel, snackbar fallback, or accessibility announcement infrastructure.
2026-09-21 14:55:32 +08:00
Miheichev Aleksandr SergeevichandClaude Opus 5 08c2a4deb1 refactor(ui): migrate Coil 2 to Coil 3 (#6248)
Coil 2 had its last release in July 2024 (2.7.0); Coil 3 is the
maintained line and uses new coordinates, io.coil-kt.coil3, with the
package prefix coil3.

AppIconFetcher now returns ImageFetchResult with drawable.asImage(),
since Coil 3 models images through its own Image type. Fetcher,
Fetcher.Factory, Options, DataSource and the ImageRequest builder chain
keep the same shapes, so the rest is imports.

No network artifact is required: AppListItem is the only AsyncImage call
site and both callers pass icon = null, so every model is the local
appicon: request served by AppIconFetcher.

Coil 3.6.3 declares minSdkVersion 23 and ships no native code, so
neither minSdk 24 nor the four app ABIs are affected.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:49:06 +08:00
wttommy-ctrl 60ab1af384 fix(root): enforce command deadlines while draining output (#6241)
* fix(root): bound command execution and output collection

* test(root): cover command deadlines and output limits
2026-09-21 14:47:18 +08:00
cinand2dust c48fbd3fc8 feat(wireguard): add remoteDNS field to server config UI (#6230)
Expose the `remoteDNS` option (added in Xray-core 26.9.9) in the wireguard.

- Accepts a comma-separated list of IPv4/IPv6 addresses
- Accepts "local" as a single value to resolve via the device's
  local DNS instead of routing lookups through the tunnel
- Falls back to WIREGUARD_LOCAL_REMOTE_DNS when the field is empty
  (Xray-core upstream value)

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-09-21 14:42:56 +08:00
Eliot the Cougar b98895219a Fix WebView Browser Dialer recovery after network handovers (#6229)
* Confine WebView dialer lifecycle to its main looper

Preserve synchronous start/stop completion and failure propagation for worker-thread network reloads. Supersede queued lifecycle requests and prevent callbacks from reviving destroyed views. Add JVM and real-WebView regression coverage for issue #6228.

* Keep WebView device tests focused on framework thread ownership

* Remove WebView dialer instrumentation tests
2026-09-21 14:32:15 +08:00
solokot 0f65b1d348 Update Russian translation [reviewed] (#6210)
* Update Russian translation

Some improvements

* fix typo

* Update Russian translation [reviewed]
2026-09-21 11:50:22 +08:00
Eliot the Cougar a1cfa2aa7a UI/UX: Add item names to deletion confirmations (#6202)
* Name the targets in deletion confirmations

Extract the visible named-confirmation wording from the accessibility assembly onto current upstream. Cover main-list profiles, standard and custom profile editors, policy groups, proxy-chain members, subscriptions, routing-rule editors, and asset sources. Reuse the six localized named variants in all nine catalogs; preserve existing generic strings and the already-named asset-file confirmation.

Carry display names alongside existing GUIDs in main/subscription list confirmation requests, and retain the main target across recreation with its two-string saver. Use saved names in editors rather than uncommitted renames. Do not import accessibility semantics, action menus, live regions, the separate routing-list Delete button, or the proxy-chain stable-key migration.

Validation: 72 JVM tests passed, including two deletion-target saver regressions; Play Store debug compilation and x86_64 assembly passed. An API 33 emulator verified all 54 compiled localized resources with Unicode and percent-containing names, the main profile confirmation across rotation, and subscription confirmation/cancel. No deletion was confirmed. Other editors, double-column menus, keyboard/D-pad, and TalkBack were not exercised. Original APK and rotation settings were restored.

* Standardize named delete confirmation layout

Center delete confirmation messages while retaining a single Text node for the question and target. Separate each item name with a blank line in all nine locales, including asset files, which previously embedded the filename in the question.

Keep the title-free dialog and existing Cancel/Delete actions, and leave ordinary confirmation messages start-aligned. Bulk-delete prompts retain their collective wording.

Validation: 72 JVM tests, Kotlin compilation and Play Store debug assembly passed. All 63 compiled localized templates preserved an exact Unicode/percent target. Emulator checks covered profile and asset message nodes, two action targets, cancellation and profile dialog recreation after rotation. Actual spoken TalkBack output and the full theme/input matrix were not verified.

* Resolve named proxy-chain confirmations by stable member key

Upstream #6200 replaced pending member indices with stable keys. The textual rebase of #6202 retained an out-of-scope index reference in the confirmation text, breaking compilation.

Look up the current member name through memberKeys, using the same pending identity as deletion. Reordering no longer separates the displayed target from the member removed on confirmation.

* Reuse deletion questions instead of duplicating named translations

Let DeleteConfirmDialog append an optional item name after a blank line while keeping the complete message in one centered Text. Reuse the existing localized questions at all named deletion sites and remove six duplicate resource keys from all nine locales. Asset-file questions use the same shared layout. Unnamed and bulk prompts remain unchanged.

Retain stable-key proxy-chain target lookup and the corrected Russian policy-group wording in the reused question. Validation: existing ServerDeleteTargetTest, all 79 JVM tests, x86_64 Play Store debug assembly and a Pixel 9 Pro emulator check passed. The profile dialog exposes one question/name text node plus Cancel/Delete, and canceling preserves the lab profiles. TalkBack was enabled for the smoke check; actual speech and the full device/theme/locale matrix were not independently verified.
2026-09-21 11:49:32 +08:00
dependabot[bot] 53fce9a8f5 Bump actions/setup-java from 6.0.0 to 6.0.1 (#6232)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v6.0.0...v6.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 16:01:23 +08:00
2dust cb39a10ada Upgrade Gradle, AGP, and core dependencies 2026-09-19 15:56:26 +08:00
Mohammad c1c93b62d1 Add finalMask parsing to Wireguard configuration handling (#6242) 2026-09-19 15:02:56 +08:00
eliotcougar a9b1242686 Coalesce bulk probe result updates (#6205) 2026-09-13 09:51:57 +08:00
eliotcougar 173f60a1da Keep connection-test requests tied to their lifecycle (#6203)
Extract request ownership and cancellation from the accessibility work without changing result wording or presentation. Correlate current and bulk replies with the request that produced them so older progress, cancellation, and completion cannot finish a replacement test.

Own current probes in the core manager and cancel them on replacement, stop, or reload. Use the existing ordered service acknowledgement to retire requests when the daemon is absent. Cancel pending bulk preparation when the UI cancels or its ViewModel is cleared, and snapshot the filtered target list before asynchronous preparation.

Tag bulk cancellation per worker, including service destruction, instead of reporting cancellation as successful completion or issuing an untagged reply. Report rejected bulk-service starts through the same cancellation path. Preserve the existing service modes, IPC payload classes, and group post-test policy.

Validation: 74 JVM tests, Kotlin compilation and Play Store debug assembly passed. External emulator probes exercised current and bulk replacement, stale replies, cancellation during preparation, daemon-absent acknowledgement, and real notification-command cancellation over 15 Lab profiles. VPN smoke testing returned a current result with IP information and preserved disconnected status after stop. Root/proxy-only and network-handover runtime checks were not run.
2026-09-11 19:46:40 +08:00
DHR60 b9c2decb34 Delete redundant fields (#6216) 2026-09-11 19:32:23 +08:00
2dust 200c59f773 up 2.3.8 2026-09-10 14:23:35 +08:00
eliotcougar 49835a5cdb Use completed-action wording and plurals for update results (#6196)
Extract the config and asset update-result resources from local/talkback-all-10 in all nine supported locales. Describe completed updates instead of an update command, and select singular or plural forms using the actual config or successful asset count.

Route both subscription update entry points through the existing localized ViewModel context and use activity resources for asset results. Preserve update execution, counts, partial-failure summaries, error handling, and toast delivery; no accessibility announcement infrastructure is included.
2026-09-10 13:56:49 +08:00
2dust 53c6506308 Update AndroidLibXrayLite 2026-09-10 12:19:02 +08:00
eliotcougar 01b47581c0 Keep proxy-chain deletion targets tied to stable row keys (#6200)
Store the pending member key instead of its list position and resolve that key when deletion is confirmed. Remove the member and its corresponding row key together; if the target no longer exists, leave the draft unchanged.

Extract the deletion fix from the accessibility assembly without importing accessibility actions, dialog wording changes, or drag refactors. Reuse the same key-based removal for blank members.

Validation: 75 JVM tests passed, including five deletion regressions; Play Store debug Kotlin compilation and x86_64 assembly passed. Emulator smoke check covered confirmation across rotation and immediate blank-member removal. Keyboard/D-pad and runtime reorder-during-confirmation scenarios were not run.
2026-09-10 12:16:17 +08:00
eliotcougar 362e541cb6 Keep built-in asset row identities stable across reloads (#6199)
Use a builtin: filename-based ID instead of generating a fresh UUID each time the asset list is rebuilt. This lets the existing GUID-keyed list and file-metadata map recognize the same built-in asset after reloads and download-source changes.

Extract the fix and its two JVM regression tests from local/talkback-all-10. Preserve stored asset GUIDs, matching built-in replacement, locked flags, and URL behavior. No persisted IDs, accessibility semantics, metadata formatting, or editor behavior are changed.
2026-09-10 11:50:59 +08:00
eliotcougar 157acc8daa Format asset metadata with localized file sizes and minute precision (#6197)
Extract the visible asset metadata formatting from local/talkback-all-10. Use Android file-size formatting and derive the date/time pattern from the app configuration locale and the device 12/24-hour preference, without seconds.

Remove the metadata-only remember cache so recomposition uses the current formatting context. Preserve the existing size/date separator, missing-file message, row layout, action buttons, and accessibility semantics. No update-result or asset-editor changes are included.
2026-09-10 11:49:29 +08:00
eliotcougar 5d76fcd0fa Generate subscription QR codes off the UI thread (#6194)
Move subscription QR bitmap generation from the synchronous share callback into the existing ViewModel, running CPU-bound encoding on Dispatchers.Default and exposing the completed bitmap through StateFlow.

Cancel pending result delivery when the dialog is dismissed, a newer share request replaces it, or the ViewModel is cleared. Keep dialog rendering lifecycle-aware and reuse the existing localized failure message when generation returns no bitmap.

Extract only QR generation and dialog-state wiring from local/talkback-all-10. Leave subscription row accessibility, custom actions, update handling, and clipboard sharing unchanged.
2026-09-10 11:35:59 +08:00
eliotcougar 4004cee279 UI/UX: Preserve asset-editor drafts during recompose (#6193)
* Preserve asset-editor drafts and handle the keyboard

Keep the asset name, URL, and delete-confirmation visibility in saveable Compose state so activity recreation does not discard an unfinished edit. Key the state by asset identity and initial field values.

Make the form vertically scrollable, consume the Scaffold padding, and apply IME padding so its fields remain reachable when the keyboard reduces the available height. Extract these presentation changes from local/talkback-all-10 without changing accessibility labels, deletion wording, or asset-save validation order.

* Resize the asset editor when the keyboard opens

Declare stateUnchanged|adjustResize for UserAssetUrlActivity, matching the other editor activities. Without an explicit resize policy, opening the IME in landscape pans the entire window and moves the toolbar Save action off-screen despite the scrollable Compose form.

Verified on Android 13 Pixel 6a: rotate with an unsaved draft, reopen the landscape keyboard, scroll between fields, edit the first field, save without hiding the keyboard, and reopen persisted values. Portrait keyboard and Save also pass. Debug assembly succeeded.
2026-09-10 11:34:20 +08:00
eliotcougar 06bcd4c16b Show per-app proxy help in a dialog (#6192)
Replace the short-lived info toast with the existing titleless confirmation dialog and a single OK button so users can read the routing explanation at their own pace. Keep dialog visibility in saveable screen state and reuse the existing localized help and action strings.

Extract only the help-dialog presentation from local/talkback-all-10; leave app-row semantics, switches, routing descriptions, and notification infrastructure in the accessibility branches.
2026-09-10 11:33:58 +08:00
eliotcougar 8d61aedeca fix: validate asset edits before deleting the cached file (#6190)
Reject duplicate names and empty names or URLs before invalidating the existing asset cache. Previously a rejected save left the stored source unchanged but deleted its downloaded file.

Preserve the validation messages and their order, duplicate-name exclusion by asset ID, and successful-save behavior. Renames still invalidate the old filename before updating the asset record.
2026-09-10 11:33:22 +08:00
2dust 2020807c25 up 2.3.7 2026-09-05 15:52:22 +08:00
2dust ca4185abad Rename daemon process and split task services
Standardizes manifest process names by replacing the long `:RunSoLibV2RayDaemon` suffix with `:daemon` for shortcut activities, VPN/proxy services, and related receivers. It also moves `CoreTestService` and `SubscriptionUpdateService` to a separate `:tasks` process to isolate task-oriented work from the main daemon process.
2026-09-05 15:47:34 +08:00
eliotcougar 8d9152bb4e Align default settings hints with actual app behavior (#6183) 2026-09-05 15:06:19 +08:00
eliotcougar 5bfcdf90ec Clarify boot auto-connect and link system VPN settings (#6179)
* Clarify device-boot connection and link system VPN settings

* Match Bakhtiari system spelling in VPN settings strings
2026-09-05 14:59:06 +08:00
eliotcougar f4f992eb9b Deduplicate subscription IDs when reading the saved index (#6172)
A saved SUB_IDS array can contain the same GUID more than once. Loading
it verbatim creates repeated subscription rows with identical Compose
and reorderable-list keys, which can crash the subscription screen.

Deduplicate IDs in the shared reader while preserving the first-seen
order and mutable-list contract. This protects every index consumer
without changing subscription records, generating replacement IDs, or
adding writes during reads. Different subscriptions with identical or
blank names remain separate.

Add six regression tests for duplicate rows, stable ordering, read-only
decoding, mutable round trips, repeated names, and existing empty/invalid
input behavior. Three assertions fail on unchanged upstream; all six
pass with the fix. The full 63-test suite and Play Store debug build pass.

On the API 37.1 Pixel 9 Pro emulator, a deliberately duplicated saved
index loaded each subscription once, retained first-occurrence order,
reopened safely, and edited the correct record. The raw index remained
duplicated throughout the check; no app crash was observed.
2026-09-05 14:54:46 +08:00
2dust 1af8f4677c Revert "Use ruleset IDs for routing edits"
This reverts commit c548e5f615.
2026-09-05 14:50:28 +08:00
eliotcougar 4d8ee1a916 Fix Logcat crashes on duplicate log lines (#6170)
Logcat used the complete log text as a Compose lazy-list key after 258de2097. Identical timestamped messages and repeated stack frames are valid log output, but collide as keys and crash the main thread while browsing logs.

Give each occurrence its own key in a small ViewModel-owned presentation model. Count oldest-first so prepending newer logs and filtering unrelated lines retain existing occurrence keys. Build the projection off the main thread and keep raw text, duplicates, ordering, copy and share behavior unchanged.

Add six regression tests covering duplicate messages, repeated frames, filtering, refresh, empty input and key-like text. All 63 Play Store unit tests pass; the debug APK was checked on the API 37.1 emulator with duplicate-line scrolling, filtering, refresh, reopening and lossless export. The original failure is documented by the user-provided physical-device crash log.
2026-09-05 14:46:10 +08:00
eliotcougar 95bcf32c0b Fix Logcat refresh button navigation inset (#6155) 2026-09-05 14:26:39 +08:00
2dust c548e5f615 Use ruleset IDs for routing edits
Switch routing ruleset lookup, save, and delete flows from list index to stable ruleset ID. Routing edit intents now pass `ruleset_id`, editing reloads by ID, and list item edit actions use each rule’s ID. This avoids index mismatch bugs when list order changes and keeps update/delete targeting the correct ruleset.
2026-08-30 19:38:43 +08:00
2dust 258de20972 Use stable list keys in Logcat and Tasker 2026-08-30 19:29:03 +08:00
2dust 3dc0173b9f Fix
https://github.com/2dust/v2rayNG/issues/6153
2026-08-30 17:44:53 +08:00
2dust 13138dddca up 2.3.6 2026-08-29 18:11:06 +08:00
2dust d5fc104e1d Fix
https://github.com/2dust/v2rayNG/issues/6140
2026-08-29 18:10:03 +08:00
2dust 384553e74e Code clean 2026-08-29 18:09:50 +08:00
eliotcougar 11b5784321 Keep HEV credentials out of root scripts (#6138) 2026-08-29 16:06:28 +08:00
eliotcougar e27941558e fix: prevent ZIP path traversal during backup restore (#6134)
* fix: harden ZIP backup extraction

* fix: tune backup ZIP limits

* refactor: simplify ZIP extraction validation
2026-08-29 16:03:57 +08:00
eliotcougar c73498b064 Docs/agent guidelines (#6129)
* docs: refresh agent guidelines

* docs: add agent harness adapters

* Update local-only block markers in AGENTS.md

DO NOT COMMIT phrase was confusing most LLMs and preventing them from committing anything without an additional explicit permission.
2026-08-29 15:59:49 +08:00
eliotcougar e2dc37ba26 Prepare server rows outside composition (#6107)
Build presentation-ready server row models in the ViewModel and publish each group's raw data and row data as one atomic state.

This removes storage and description work from composition, reduces the server-row API, and keeps reordering consistent across both representations.
2026-08-29 15:58:33 +08:00
eliotcougar 26099c198e Improve Compose group paging (#6105) 2026-08-29 15:53:37 +08:00
2dust 3c9336f671 Bump Android Gradle Plugin to 9.3.2 2026-08-29 15:49:36 +08:00
patterniha 767f88667c Add happyEyeballs to the freedom outbound (#6148) 2026-08-29 11:08:24 +08:00
patterniha e3fa861571 Do not remove policy levels when traffic statistics is disabled (#6146)
Disabling traffic statistics removed the whole policy section from the
generated config, including the levels block (uplinkOnly, downlinkOnly,
connIdle, handshake) from the template or from a user's custom config.
Only the stats-related policy.system block should be stripped; keep
policy.levels intact in both the unified and the custom config paths.
2026-08-29 11:07:00 +08:00
dependabot[bot] fdacd58ed0 Bump actions/setup-java from 5.7.0 to 6.0.0 (#6145)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.7.0 to 6.0.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5.7.0...v6.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 11:05:30 +08:00
DHR60 38da97e031 Fix (#6150) 2026-08-29 11:05:07 +08:00
eliotcougar a1b45bbfb2 Use stable identifiers to locate servers (#6103) 2026-08-23 19:33:27 +08:00
eliotcougar dac65a972c Fix IP and CIDR validation (#6124) 2026-08-23 15:56:32 +08:00
Tamim Hossain 63f557242b closes #6070 (#6114)
cc @2dust
2026-08-21 10:21:03 +08:00
2dust 9fcb1a30f8 up 2.3.5 2026-08-20 17:09:55 +08:00
2dust d4fc91502c Update AndroidLibXrayLite 2026-08-20 15:21:33 +08:00
2dust 75c9194ec0 Log CoreCallback events and noop shutdown 2026-08-20 15:16:47 +08:00
aliRahimi1997 590be2e9ae fix(accessibility): announce selected server state (#6108) 2026-08-19 20:28:38 +08:00
aliRahimi1997 1ee1213563 fix(accessibility): label more options buttons (#6095) 2026-08-19 20:14:59 +08:00
eliotcougar 37b42f04a7 Load user asset metadata off the UI thread (#6106)
Load asset metadata on Dispatchers.IO and publish the asset list and file details as one lifecycle-aware UI state.

Remove refresh-key workarounds, await reloads before downloads, and keep the content composable stateless.
2026-08-19 14:16:41 +08:00
eliotcougar 3c91b51489 Optimize large-group probing startup (#6102)
Clear visible delays immediately, but move the per-profile MMKV reset to the existing I/O coroutine. Finish the reset before starting the test service so newly reported results cannot be erased.
2026-08-19 13:57:06 +08:00
eliotcougar 06a27ec4d4 Allow TCPing mixed H3/H2 profiles (#6101) 2026-08-19 13:55:26 +08:00
eliotcougar 7a07565f72 Harden profile replacement ordering (#6093) 2026-08-17 10:20:43 +08:00
eliotcougar 0456f835bc Add manual profile storage cleanup (#6094) 2026-08-17 10:06:56 +08:00
aliRahimi1997 e4e3a36e93 fix(accessibility): label server action buttons (#6091) 2026-08-16 19:44:24 +08:00
2dust 4b3c8177db Add Duration-based delay extension helpers 2026-08-16 15:32:35 +08:00
eliotcougarand2dust 780d194213 Make explicit service restart daemon-authoritative (#6072)
* Make service restarts daemon-authoritative

* Update CoreServiceManager.kt

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-08-16 15:26:58 +08:00
eliotcougar c1374a73fb fix(ui): use asset-specific update toast (#6088) 2026-08-16 10:55:31 +08:00
eliotcougar 49fe45c49e Recover MMKV data on storage errors (#6085) 2026-08-16 10:30:56 +08:00
eliotcougar 36ab5615d9 Fix custom real-delay probe crash (#6083) 2026-08-16 10:21:17 +08:00
2dust 91c2c93a62 Adjust string 2026-08-16 10:14:32 +08:00
aliRahimi1997 10932f5764 fix(accessibility): announce server test status changes (#6082) 2026-08-15 19:53:38 +08:00
Xiaochang 8dcff4238f Fix bottom bar background v2 (#6075)
* fix: extend bottom bar background and content to screen edge for edge-to-edge

* Extract NavigationBarsSpacer and navigationBarsBottomPadding to reduce duplicate code

* Rename navigationBarsBottomPadding to NavigationBarsBottomPadding for naming consistency
2026-08-15 10:39:14 +08:00
eliotcougar 809bdcdb9d Use action labels for service shortcuts (#6074) 2026-08-15 10:32:03 +08:00
2dust b348ca792b up 2.3.4 2026-08-14 20:33:23 +08:00
eliotcougar 1fa0ccb4f2 Make proxy-only service starts idempotent (#6071) 2026-08-14 20:26:13 +08:00
2dust 3033e471cd Use theme secondary color in Compose UI 2026-08-14 15:30:10 +08:00
Xiaochang 36796fee37 feat: replace app name with app icon in navigation drawer header - Use ic_launcher_foreground 120dp - Dark mode: icon tinted white - Light mode: original colors - Surface height 180dp (#6068) 2026-08-14 15:11:26 +08:00
Xiaochang 2607b5cbff feat: support Monet dynamic color (Android 12+) - Add toggle in UI settings, default enabled, auto-disable on unsupported devices (#6067) 2026-08-14 15:09:46 +08:00
eliotcougarand2dust 9cdbc5cebb fix: localize runtime-generated UI text (5/3) (#6049)
* Localize runtime-generated UI text

Keep connection-test and status data locale-neutral until the UI formats it, and localize delay units at their Compose presentation boundary.\n\nShow stable localized failures while retaining diagnostic exceptions in Logcat. Clarify routing rule matching and subscription entry/exit proxy behavior at the relevant UI call sites, and remove the now-unused core error resources.

* Fix Compose resource lookup in routing editor

* Adjust string

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-08-14 14:35:47 +08:00
mola f976d397e0 Make update notes scrollable (#6054) 2026-08-14 14:16:25 +08:00
DHR60 a16a7390f4 Fix kcp and sync finalmask order (#6062) 2026-08-14 14:13:09 +08:00
eliotcougar 606246f36e fix: make root service starts idempotent (#6055) 2026-08-14 14:09:31 +08:00
eliotcougar e8a82d9810 fix: refine existing locale translations (4/3) (#6048)
* Refine existing locale translations

Carry over the standalone wording and translation corrections from the combined localization audit across all nine maintained catalogs.

Keep identifiers, order, placeholders, and metadata unchanged. Exclude resource additions, removals, and wording that depends on locale API, connection-result, routing layout, subscription proxy, delay-unit, or core code changes.

* Clarify profile export actions

Distinguish copying a profile share link from copying the fully generated configuration in every maintained locale.
2026-08-10 15:03:26 +08:00
eliotcougar 739e303fc0 Fix per-app locale handling across contexts (#6047)
Use AndroidX and framework per-app locale APIs, migrate the existing language preference, and expose the generated locale config to Android 13+ settings.

Resolve resources through the selected app locale in application, ViewModel, tile, and service contexts. Replace the framework OK and Cancel labels directly implicated in the wrong-language report, and refresh retained localized UI state after locale changes.
2026-08-09 21:09:41 +08:00
2dust f63f649477 Adjust UI 2026-08-08 10:51:51 +08:00
2dust a2be68f7c5 Use internal assets directory path
https://github.com/2dust/v2rayNG/issues/6035
2026-08-08 10:12:35 +08:00
2dust 68f9e66a76 Bug fix
https://github.com/2dust/v2rayNG/issues/6027
2026-08-07 20:30:15 +08:00
2dust 9be547c3b9 up 2.3.3 2026-08-07 16:27:33 +08:00
2dust fd48dcf9e0 Simplify core error handling paths
https://github.com/2dust/v2rayNG/pull/6025
2026-08-07 16:24:20 +08:00
eliotcougarand2dust 775546ce40 fix: localize user-facing and accessibility text (2/3) (#6025)
* fix: localize UI and accessibility text

* fix: translate BQI core errors

* fix: correct Bakhtiari orthography and wording

Replace the remaining Persian confirmation fallbacks, correct stale or mismatched UI meanings, and normalize established Bakhtiari terms without translating xray-core identifiers.

Primary source: Erik Anonby and Ashraf Asadi, Bakhtiari Studies II: Orthography (2018), sections 6.1.1, 6.8.1, 6.8.2, 6.8.7, and 6.8.9. https://www.diva-portal.org/smash/get/diva2%3A1231755/FULLTEXT02.pdf

The standard recommends that "Arabic letters be retained for Bakhtiari words borrowed from Persian" and reports that speakers view soft d as "a defining feature of how the language is pronounced."

Lexicon and phonology source: Erik Anonby and Ashraf Asadi, Bakhtiari Studies: Phonology, Text, Lexicon (2014). https://uu.diva-portal.org/smash/record.jsf?pid=diva2%3A758171

Historical corroboration: D. L. R. Lorimer, The Phonology of the Bakhtiari, Badakhshani, and Madaglashti Dialects of Modern Persian (1922). https://archive.org/details/phonologyofbakht06loriuoft

* fix: keep protocol description technical

* fix: keep Logcat diagnostics unlocalized

* getRemoteIPInfo

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-08-07 15:58:11 +08:00
2dust d8b857665c Move update check out of Compose 2026-08-06 17:12:26 +08:00
eliotcougarand2dust 17d03bcc46 Optimize app picker filtering and bulk selection (#6000)
* Optimize app picker filtering and selection

Avoid per-query string copies and repeated state persistence or service restart requests during bulk selection. Preserve the active filter while apps load, calculate automatic and imported selections away from the main thread, and keep upstream's asynchronous Coil icon pipeline unchanged.

* Remove enabled: Boolean = true

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-08-06 17:04:39 +08:00
eliotcougar efb16e7979 fix: sync locale resources and translations (1/3) (#6024)
* fix: align and complete locale resources

* fix: finish locale translations

* fix: align routeOnly terminology

* fix: preserve core terminology in translations

* fix: refine localization wording

* fix: finish localization resource cleanup

* fix: standardize URL capitalization

* fix: mark technical resources non-translatable
2026-08-06 08:34:26 +08:00
eliotcougar 5ac660e395 Use typed actions for Compose menus and Material 3 drawer components (#6016)
* Use typed actions for application menus

* Use Material 3 navigation drawer components

Replace the custom drawer rows with NavigationDrawerItem and route system Back through Compose so an open drawer closes before the task is backgrounded.

* Polish typed menus and drawer integration
2026-08-05 14:59:27 +08:00
2dust 2dcc7781a5 perf: optimize app icon loading with asynchronous Coil retrieval
- Remove `appIcon: Drawable` from `AppInfo` to significantly reduce memory footprint and prevent OOM.
- Replace synchronous icon loading in `AppManagerUtil` with on-demand asynchronous loading.
- Implement custom `AppIconFetcher` using `appicon:` URI scheme for system icon retrieval.
- Use localized `fetcherFactory` in `AppListItem` to keep loading logic isolated and minimize global side effects.
- Improve app list scanning speed by skipping icon extraction during initial load.
2026-08-04 21:22:19 +08:00
Alexandr 2a51e7e767 fix(speed): resolve 0 B/s speed notification counters for custom subscription profiles (#5990)
* fix(speed): fix speed counters showing 0 for custom subscription profiles

Fix an issue where proxy speed counters in the notification widget remain at 0 B/s for custom subscription profiles.
1. NotificationManager: Accumulate traffic stats for all non-direct and non-blocked outbound tags (stat.tag != TAG_BLOCKED) into proxy stats. Custom outbound tags in subscription JSONs (e.g., 'node-1', 'vless-hk', 'auto') are now correctly accounted for instead of being ignored.
2. CoreConfigManager: Ensure stats and policy (statsOutboundUplink / statsOutboundDownlink) sections are injected into custom JSON configurations when PREF_SPEED_ENABLED is enabled, and stripped when disabled.

* Comments added
2026-08-04 20:31:26 +08:00
solokot 007e167a45 Update Russian translation (#5987) 2026-08-04 20:30:49 +08:00
dependabot[bot] 9c80ab3d44 Bump actions/setup-java from 5.6.0 to 5.7.0 (#6019)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.6.0 to 5.7.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5.6.0...v5.7.0)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.7.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-04 20:30:35 +08:00
eliotcougar 320fb6a10b Handle padded camera rows in QR scanner (#6012)
Camera HALs may expose a Y plane whose row stride is wider than the visible image. Pass that stride to ZXing so padded rows remain aligned, and cover the vendor-layout case with a valid VLESS QR regression test.
2026-08-04 20:30:25 +08:00
2dust 559a00e66e Fix cancel signaling in measure/ping services 2026-08-03 20:48:37 +08:00
eliotcougar 31dfaeed6c Add cancel action to batch-test notification (#6009) 2026-08-03 20:42:04 +08:00
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
2dust b103e3aec9 up 2.2.3 2026-06-02 18:39:35 +08:00
2dust 19fc65d128 Update allowInsecure deprecation messages and toast 2026-06-02 18:27:37 +08:00
2dust 27cafd1052 Deprecate allowInsecure and show warning toast
https://github.com/2dust/v2rayNG/issues/5717
2026-06-02 17:02:10 +08:00
2dust a155b24c1e up 2.2.2 2026-06-01 16:02:15 +08:00
2dust e805fcf088 Fix
https://github.com/2dust/v2rayNG/issues/5720
2026-06-01 15:55:06 +08:00
2dust 95e37db42e Update AndroidLibXrayLite 2026-06-01 15:05:39 +08:00
2dust fe812c4456 Remove allowInsecure preference and default to secure 2026-06-01 15:04:26 +08:00
fuilloi d5af0fe735 Update build.yml (#5718) 2026-06-01 13:46:07 +08:00
Hossein Abaspanah 9126aa9c9e Update Luri Bakhtiari translation (#5713)
* Update strings.xml

* Update strings new.xml

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

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

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

* Update MainRecyclerAdapter.kt

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

fix: migrate wsSettings host out of deprecated headers field

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

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

fix: migrate wsSettings host out of deprecated headers field

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

* Update strings new.xml

* Update strings.xml
2026-05-12 08:57:54 +08:00
2dust b5bcd81ae4 up 2.1.7 2026-05-10 14:17:02 +08:00
2dust d45ccd7953 Update AndroidLibXrayLite 2026-05-10 14:16:54 +08:00
solokot 6815563902 Update Russian translation (#5621) 2026-05-10 14:07:00 +08:00
2dust 7c38ad7c57 Improve WireGuard addresses 2026-05-09 16:48:09 +08:00
2dust 885aeb384e Refactor TCP HTTP request builder, relax Pragma
https://github.com/2dust/v2rayNG/issues/5623
2026-05-09 16:35:11 +08:00
368 changed files with 25933 additions and 16684 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# Agent-guide adapter
Before starting any repository task, open and follow the repository-root `AGENTS.md`.
That guide defines the scoped `AGENTS.md` files that must also be opened before editing
their paths. Treat the root and applicable scoped guides as mandatory. Do not duplicate
or replace their rules in this file.
+50 -17
View File
@@ -10,37 +10,40 @@ on:
branches:
- master
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup Android SDK
uses: android-actions/setup-android@v4.0.1
uses: android-actions/setup-android@v4
with:
log-accepted-android-sdk-licenses: false
cmdline-tools-version: '13114758'
packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
cmdline-tools-version: '14742923'
packages: 'platforms;android-37.0 build-tools;37.0.0 platform-tools'
- name: Install NDK
run: |
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
--channel=0 \
--install "ndk;28.2.13676358"
echo "NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> $GITHUB_ENV
--install "ndk;30.0.16248370"
echo "NDK_HOME=$ANDROID_HOME/ndk/30.0.16248370" >> $GITHUB_ENV
sed -i '10i\
\
ndkVersion = "28.2.13676358"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
ndkVersion = "30.0.16248370"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
- name: Restore cached libhevtun
id: cache-libhevtun-restore
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: ${{ github.workspace }}/libs
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
@@ -52,7 +55,7 @@ jobs:
- name: Save libhevtun
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
uses: actions/cache/save@v6
with:
path: ${{ github.workspace }}/libs
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
@@ -78,7 +81,7 @@ jobs:
out-file-path: V2rayNG/app/libs/
- name: Setup Java
uses: actions/setup-java@v5
uses: actions/setup-java@v6.1.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
+3
View File
@@ -21,6 +21,9 @@ V2rayNG/app/build
V2rayNG/build
V2rayNG/local.properties
# Ignore prebuilt native libraries (built from submodules by compile-hevtun.sh / AndroidLibXrayLite)
V2rayNG/app/libs
# Ignore APK and AAR files
*.apk
*.aar
+188
View File
@@ -0,0 +1,188 @@
# v2rayNG agent guide
## Scope and precedence
This file applies to the entire repository. Read the applicable scoped guide before editing any
listed path, including when the agent starts at the repository root:
- Read `V2rayNG/app/src/main/java/com/v2ray/ang/service/AGENTS.md` for
`V2rayNG/app/src/main/java/com/v2ray/ang/service/`,
`V2rayNG/app/src/test/java/com/v2ray/ang/service/`, and these paths under
`V2rayNG/app/src/main/java/com/v2ray/ang/`: `core/CoreServiceManager.kt`,
`core/LauncherManager.kt`, `root/`, `contracts/ServiceControl.kt`,
`contracts/Tun2SocksControl.kt`, `handler/NotificationManager.kt`,
`helper/MessageHelper.kt`, and `helper/NotificationHelper.kt`.
- Read `V2rayNG/app/src/main/java/com/v2ray/ang/ui/AGENTS.md` for
`V2rayNG/app/src/main/java/com/v2ray/ang/ui/` and
`V2rayNG/app/src/test/java/com/v2ray/ang/ui/`.
Follow both guides in scoped paths. A scoped rule overrides a root rule only when they require
incompatible actions; every other root rule remains active.
## Requirement language
- `must`, `must not`, `only`, and `never` are mandatory. Only the current user request or linked
acceptance criteria can state an exception; agent inference cannot.
- `Task scope` is the named files and behavior plus dependency, test, or build edits that a
compiler or required validation proves necessary. Every other change is outside scope.
- `Changed behavior` includes direct and downstream build-time or runtime effects through callers,
consumers, storage, IPC, and resources.
- `Supported` versions, ABIs, distributions, and locales are exactly those declared by
`V2rayNG/app/build.gradle.kts` and `.github/workflows/build.yml` inputs.
- A behavior is `verified` only when its named check passed on branch HEAD. Otherwise list the
exact unrun check and blocker, and do not call the behavior verified.
## Existing code is not a compliance baseline
- These guides define the target state; they do not claim that this checkout, an older commit, or
another worktree complies. Existing code shows current behavior and is not permission to repeat
a pattern.
- A `touched declaration` is any function, property, class, composable, resource entry, manifest
element, or Gradle block containing an added or removed line. Inspect it and every caller or
consumer whose contract changes against all applicable rules before editing.
- Every new declaration and changed behavior must comply. Inspect a helper or pattern before
reusing it. Tests prove only their assertions; names, green builds, and prior approval prove
nothing else.
- Do not repair unrelated violations. Fix a violation left on a task-changed execution path in the
same branch; if that would change behavior outside scope, request approval first. Report any
remaining violation that blocks required validation.
## Agent-guide synchronization and PR boundary
- `AGENTS.md` files are the only rule source. `CLAUDE.md`, `GEMINI.md`, and
`.github/copilot-instructions.md` are routing shims and must only load or point to applicable
`AGENTS.md` files.
- Feature, fix, and release branches use instruction files from fetched `upstream/master` as their
baseline. If `upstream` is absent, run
`git remote add upstream https://github.com/2dust/v2rayNG.git`; otherwise require
`git remote get-url upstream` to print exactly that URL. Successfully run
`git fetch upstream master` before comparison. An agent-instruction branch uses its HEAD and
working diff as the proposal.
- At task start and after each upstream merge or rebase, synchronize canonical instructions into
the worktree without deleting local-only text. Compare them with:
```sh
git diff upstream/master -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
```
After synchronization, every displayed difference must be inside a local-only block. If a branch
predates the files, keep copied versions untracked or unstaged; acquire tracked copies only by
merging or rebasing their upstream commit.
- User-, machine-, worktree-, and temporary text is local-only. Put it in `AGENTS.md` between
visible lines `LOCAL INSTRUCTIONS - START` and `LOCAL INSTRUCTIONS - END`.
Unless the task targets agent instructions, treat every pre-existing instruction-file difference
as local-only.
- Keep local-only text unstaged and uncommitted. Never use `git add .`, `git add -A`, or
`git commit -a` in that worktree. Stage task paths explicitly; never delete, restore, or overwrite
local instructions to clean the worktree.
- Before each commit, push, or PR update outside agent-instruction scope, run both commands. Both
must print no paths; otherwise stop and remove the instruction change from the staged or branch
diff without altering its working copy.
```sh
git diff --cached --name-only -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
git diff --name-only upstream/master...HEAD -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
```
- Commit instruction files only for a task explicitly limited to agent instructions. The branch
and PR may contain only `AGENTS.md` and necessary routing-shim changes; name agent instructions
in the PR title and state that exclusive scope in the body.
## Project boundaries
- The Android project is under `V2rayNG/` and uses Kotlin, Gradle Kotlin DSL, Compose, Material 3,
coroutines, and AndroidX lifecycle. Read dependency versions from
`V2rayNG/gradle/libs.versions.toml`, SDK levels from `V2rayNG/app/build.gradle.kts`, and CI tools
from `.github/workflows/build.yml`; never copy those numbers into a guide.
- `AngApplication` initializes MMKV, app locales, WorkManager, defaults, and theme state. Persist
application data through `MmkvManager` or `SettingsManager`; do not create `SharedPreferences`
or another path for data they own.
- `core/` owns native configuration and lifecycle, `handler/` data and application operations,
`service/` Android services, and `ui/` Compose activities, components, and ViewModels. Put new
code in its owner unless a scoped guide names a narrower owner.
- Shared lifecycle and configuration code must retain VPN, proxy-only, and root-mode branches.
Change one mode only when task scope names it; gate the change and preserve the other modes.
- `AndroidLibXrayLite` is native-core source. `V2rayNG/app/libs/libv2ray.aar` and the HEV libraries
are generated inputs; do not modify or commit them unless task scope upgrades a native dependency.
Such a PR must identify the source revision and verify every app-declared ABI.
## Build and validation
Before building a clean checkout, initialize submodules recursively and reproduce the HEV and AAR
steps in `.github/workflows/build.yml`. The AAR revision must match the `AndroidLibXrayLite` gitlink
from `git ls-tree HEAD AndroidLibXrayLite`. Take build-tool versions from that workflow and
`V2rayNG/gradle/libs.versions.toml`.
Run Gradle from `V2rayNG/`, using `gradlew.bat` in Windows PowerShell or `./gradlew` on POSIX. Use
the Play Store debug variant unless task scope names another distribution.
Apply every matching validation rule:
- Documentation only: run `git diff --check`; no Gradle task is required.
- Kotlin/Java production code: run the test class that asserts each changed behavior, then
`:app:testPlaystoreDebugUnitTest` and `:app:compilePlaystoreDebugKotlin`.
- Resources, manifest, Gradle, dependencies, or native packaging: run
`:app:assemblePlaystoreDebug`.
- F-Droid only: replace `Playstore` with `Fdroid`. Run both variants when shared code branches on a
flavor, `BuildConfig.DISTRIBUTION`, or a flavor-specific resource or dependency.
- Android service lifecycle, framework callback, native interaction, permission, accessibility,
focus, or state restoration: also run an emulator or physical-device check.
Add or update JUnit tests under `V2rayNG/app/src/test/java/` for changed deterministic logic. Report
an unavailable required check under the exact label `Not run`, with its command or scenario and
reason. A compile or assembled APK does not verify runtime behavior.
There is no enforced Kotlin formatter. Preserve local indentation and import order. Do not change
whitespace outside touched declarations or reorder imports except as the diff requires.
## Repository-wide coding rules
- Keep the diff inside task scope; exclude unrelated refactors, upgrades, generated files,
translation cleanup, renames, and formatting-only changes.
- Use server GUIDs and group IDs for persistence, asynchronous work, and UI state. Never use a
list index, adapter position, or paging position as server or group identity.
- Run disk, network, package-manager, native, bitmap, and CPU-intensive work off the main thread.
Own each asynchronous operation with a named lifecycle or ViewModel scope and cancel it when the
owner ends or newer work supersedes it.
- Put all visible and accessibility text in Android resources. Update every locale in
`androidResources.localeFilters` for each changed key, preserving placeholders, plurals, and
formatting tags.
- Never rename or delete a persisted, serialized, routing, import, or export field without a
backward-compatible migration and a regression test reading the preceding released format.
Multi-record writes must complete together or restore/remove partial writes on every failure.
- Log recoverable failures through `LogUtil`, including operation, mode/component, non-secret
stable ID when available, and exception. Never log credentials, full proxy URLs, private keys,
or exported configurations.
- Before adding a constant, helper, manager, repository, or state holder, search its owner. Extend
an owner that controls the same data or lifecycle. Create a new abstraction only for different
ownership or lifetime, and state that distinction in code or the PR.
- A platform workaround needs a comment naming its Android/API or vendor boundary, prevented
failure, and exact removal condition.
## Deprecated, experimental, and version-gated APIs
- Introduce a deprecated API only for a supported-version fallback lacking the replacement, a
required deprecated callback/interface member, or a documented platform/vendor defect that
blocks acceptance criteria. The defect requires a test or linked upstream issue.
- Isolate each permitted deprecated call in one compatibility function or adapter. Comment the
reason and an exact removal trigger. Use the stable API wherever available.
- Scope `@Suppress("DEPRECATION")` or `@SuppressLint("<IssueId>")` to the containing expression or function.
Class scope is allowed only when every member implements the same required deprecated interface;
file, package, and module scope is forbidden. Never suppress permission, background-execution,
lifecycle, or security requirements.
- Guard every API above `minSdk` by SDK or extension version in the same function, or use
`@RequiresApi` only when every entry point is guarded. Mark reusable guards
`@ChecksSdkIntAtLeast`. Lower versions must use an
existing API, return explicit unsupported status, or hide/disable the feature; never crash or
report false success.
- Add or expand an experimental API only when no stable API meets named acceptance criteria, its
dependency is pinned in `libs.versions.toml`, a project-owned stable interface contains it and
excludes it from persisted data, IPC payloads, and public shared contracts, a regression test
covers it, and a comment names the opt-in marker and exact reevaluation condition.
- Put `@OptIn` on the direct function/property, or on a class only when multiple members need it.
File and module opt-ins are forbidden. Existing experimental use outside scope does not permit a
new call site.
- A preview/canary SDK or alpha dependency is allowed in production only when the request or linked
issue requires it. Keep a stable path for supported non-preview devices, gate by runtime API or
feature availability, and test both paths. After an API guard, opt-in, or lint suppression, run
`:app:lintPlaystoreDebug` and also `:app:lintFdroidDebug` when behavior differs by distribution.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+72 -14
View File
@@ -3,30 +3,88 @@
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
[![API](https://img.shields.io/badge/API-24%2B-yellow.svg?style=flat)](https://developer.android.com/about/versions/lollipop)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.3.0-blue.svg)](https://kotlinlang.org)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.4.0-blue.svg)](https://kotlinlang.org)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/2dust/v2rayNG)](https://github.com/2dust/v2rayNG/commits/master)
[![CodeFactor](https://www.codefactor.io/repository/github/2dust/v2rayng/badge)](https://www.codefactor.io/repository/github/2dust/v2rayng)
[![GitHub Releases](https://img.shields.io/github/downloads/2dust/v2rayNG/latest/total?logo=github)](https://github.com/2dust/v2rayNG/releases)
[![Chat on Telegram](https://img.shields.io/badge/Chat%20on-Telegram-brightgreen.svg)](https://t.me/v2rayn)
### Telegram Channel
[github_2dust](https://t.me/github_2dust)
---
### Usage
## Download / 下载
Download the latest release here:
在这里下载最新版本:
[https://github.com/2dust/v2rayNG/releases](https://github.com/2dust/v2rayNG/releases)
> [!TIP]
> v2rayNG is the mobile version. For the desktop version, please visit the v2rayN \
> v2rayNG 是手机版,电脑版请访问 v2rayN
>
> https://github.com/2dust/v2rayN
---
### Geoip and Geosite
#### Geoip and Geosite
- geoip.dat and geosite.dat files are in `Android/data/com.v2ray.ang/files/assets` (path may differ on some Android device)
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (Note it need a working proxy)
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (note: it needs a working proxy)
- latest official [domain list](https://github.com/Loyalsoldier/v2ray-rules-dat) and [ip list](https://github.com/Loyalsoldier/geoip) can be imported manually
- possible to use third party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
- possible to use a third-party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
### More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
### Development guide
### Geoip 与 Geosite
Android project under V2rayNG folder can be compiled directly in Android Studio, or using Gradle wrapper. But the v2ray core inside the aar is (probably) outdated.
The aar can be compiled from the Golang project [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite).
For a quick start, read guide for [Go Mobile](https://github.com/golang/go/wiki/Mobile) and [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/)
- geoip.dat 和 geosite.dat 文件位于 `Android/data/com.v2ray.ang/files/assets`(部分设备路径可能不同)
- 下载功能将获取该 [仓库](https://github.com/Loyalsoldier/v2ray-rules-dat) 中的增强版本(注意:此功能需要一个可用的代理)
- 最新官方 [域名列表](https://github.com/Loyalsoldier/v2ray-rules-dat) 和 [IP 列表](https://github.com/Loyalsoldier/geoip) 可手动导入
- 也可在同一文件夹中使用第三方 dat 文件,例如 [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
v2rayNG can run on Android Emulators. For WSA, VPN permission need to be granted via
`appops set [package name] ACTIVATE_VPN allow`
更多内容请见我们的 [wiki](https://github.com/2dust/v2rayNG/wiki)
---
## Development guide / 开发指南
### Note
- Android project under the V2rayNG folder can be compiled directly in Android Studio, or using the Gradle wrapper. But the v2ray core inside the aar is (probably) outdated.
- The aar can be compiled from the Golang project [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite). For a quick start, read the guides for [Go Mobile](https://github.com/golang/go/wiki/Mobile) and [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/).
- v2rayNG can run on Android Emulators. For WSA, VPN permission needs to be granted via `appops set [package name] ACTIVATE_VPN allow`.
### 提示
- V2rayNG 文件夹下的 Android 项目可直接在 Android Studio 中编译,或使用 Gradle wrapper 编译。但 aar 内置的 v2ray core(可能)已过时。
- aar 可由 Golang 项目 [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) 或 [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite) 编译而成。快速入门可参考 [Go Mobile](https://github.com/golang/go/wiki/Mobile) 指南和 [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/)。
- v2rayNG 可在 Android 模拟器上运行。对于 WSA,需要通过 `appops set [package name] ACTIVATE_VPN allow` 授予 VPN 权限。
---
## GPG Verification / GPG 签名校验
Release files are signed with GPG to verify authenticity and integrity, helping prevent mirror, ISP, or CDN hijacking.
发布文件已使用 GPG 签名,可用于校验文件真实性与完整性,预防镜像站、运营商或 CDN 劫持。
### Fingerprint / 公钥指纹
```text
7694 5E9F 3E9A 168F 8070 F195 805D 661C
134D FAF6 8903 C199 463C 31E5 AE90 3AE0
```
---
## Community / 社区
Telegram Group / Telegram 群组:
[https://t.me/v2rayN](https://t.me/v2rayN)
Telegram Channel / Telegram 频道:
[https://t.me/github_2dust](https://t.me/github_2dust)
+59 -31
View File
@@ -1,27 +1,27 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.jaredsburrows.license")
}
android {
namespace = "com.v2ray.ang"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 36
versionCode = 726
versionName = "2.1.6"
multiDexEnabled = true
targetSdk = 37
versionCode = 754
versionName = "2.3.14"
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,24 @@ android {
}
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"en",
"zh-rCN",
"zh-rTW",
"vi",
"ru",
"fa",
"ar",
"az",
"bn",
"bqi-rIR"
)
}
packaging {
@@ -134,6 +150,17 @@ android {
}
}
testOptions {
unitTests.all {
it.useJUnitPlatform()
// Treat Jupiter discovery warnings as failures: JUnit 4 failed on an invalid @Test
// method, such as one that returns a value, while Jupiter only warns and skips it.
// The key is @API(status = EXPERIMENTAL) in JUnit 6; after a JUnit upgrade, check
// that a temporary `@Test fun probe() = 1` still fails the unit test task.
it.systemProperty("junit.platform.discovery.issue.severity.critical", "WARNING")
}
}
}
dependencies {
@@ -143,19 +170,20 @@ 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)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
// Data and Storage Libraries
implementation(libs.mmkv.static)
@@ -166,31 +194,31 @@ 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)
testImplementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
testImplementation(libs.org.mockito.mockito.inline)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
coreLibraryDesugaring(libs.desugar.jdk.libs)
}
+11 -11
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,38 +26,38 @@
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScScannerActivity"
android:targetClass="com.v2ray.ang.ui.shortcut.ScScannerActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_start_24dp"
android:shortcutDisabledMessage="@string/toast_services_start"
android:shortcutDisabledMessage="@string/acc_start"
android:shortcutId="shortcuts_start"
android:shortcutLongLabel="@string/toast_services_start"
android:shortcutShortLabel="@string/toast_services_start">
android:shortcutLongLabel="@string/acc_start"
android:shortcutShortLabel="@string/acc_start">
<!--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>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_stop_24dp"
android:shortcutDisabledMessage="@string/toast_services_stop"
android:shortcutDisabledMessage="@string/acc_stop"
android:shortcutId="shortcuts_stop"
android:shortcutLongLabel="@string/toast_services_stop"
android:shortcutShortLabel="@string/toast_services_stop">
android:shortcutLongLabel="@string/acc_stop"
android:shortcutShortLabel="@string/acc_stop">
<!--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>
</shortcuts>
</shortcuts>
+104 -38
View File
@@ -29,16 +29,16 @@
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
android:minSdkVersion="34" />
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
@@ -52,8 +52,17 @@
android:theme="@style/AppThemeDayNight.NoActionBar"
android:usesCleartextTraffic="true">
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<activity
android:name=".ui.MainActivity"
android:name=".ui.main.MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
@@ -71,75 +80,106 @@
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:windowSoftInputMode="stateUnchanged|adjustResize"
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:process=":daemon"
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:process=":daemon"
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"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
@@ -162,14 +202,17 @@
</intent-filter>
</activity>
<activity
android:name=".ui.CheckUpdateActivity"
android:name=".ui.checkupdate.CheckUpdateActivity"
android:exported="false" />
<activity
android:name=".ui.BackupActivity"
android:name=".ui.backup.BackupActivity"
android:exported="false" />
<activity
android:name=".ui.AboutActivity"
android:exported="false" />
<activity
android:name=".ui.TranslatorsActivity"
android:exported="false" />
<service
android:name=".service.CoreVpnService"
@@ -178,7 +221,7 @@
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
tools:ignore="VpnServicePolicy">
<intent-filter>
<action android:name="android.net.VpnService" />
@@ -196,7 +239,18 @@
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service
android:name=".service.CoreRootService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":daemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
@@ -206,7 +260,17 @@
android:name=".service.CoreTestService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
android:process=":tasks">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
</service>
<service
android:name=".service.SubscriptionUpdateService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":tasks">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
@@ -215,7 +279,7 @@
<receiver
android:name=".receiver.WidgetProvider"
android:exported="true"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_provider" />
@@ -231,6 +295,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>
@@ -241,7 +307,7 @@
android:icon="@drawable/ic_stat_name"
android:label="@string/app_tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
@@ -251,7 +317,7 @@
</service>
<!-- =====================Tasker===================== -->
<activity
android:name=".ui.TaskerActivity"
android:name=".ui.shortcut.TaskerActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher">
<intent-filter>
@@ -262,7 +328,7 @@
<receiver
android:name=".receiver.TaskerReceiver"
android:exported="true"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
@@ -0,0 +1,68 @@
[
{
"language": "العربية (Arabic)",
"contributors": [
{ "name": "@mribrahem", "displayName": "ibrahem Qasim", "url": "https://github.com/mribrahem" }
]
},
{
"language": "বাংলা (Bengali)",
"contributors": [
{ "name": "@codewithtamim", "displayName": "Tamim Hossain", "url": "https://github.com/codewithtamim" }
]
},
{
"language": "لۊری بختیاری (Luri Bakhtiari)",
"contributors": [
{ "name": "@hosseinabaspanah", "displayName": "Hossein Abaspanah", "url": "https://github.com/hosseinabaspanah" },
{ "name": "@codewithtamim", "displayName": "Tamim Hossain", "url": "https://github.com/codewithtamim" }
]
},
{
"language": "فارسی (Persian)",
"contributors": [
{ "name": "@themrvx", "displayName": "MRVX", "url": "https://github.com/themrvx" },
{ "name": "@skh-web6982", "url": "https://github.com/skh-web6982" },
{ "name": "@ptechgithub", "displayName": "Peyman", "url": "https://github.com/ptechgithub" },
{ "name": "@pk-web6936" },
{ "name": "@alphax-hue3682" },
{ "name": "@phoenix6936" },
{ "name": "@decorativefamily" },
{ "name": "@decorativeman" },
{ "name": "@mh292929", "displayName": "MH", "url": "https://github.com/mh292929" },
{ "name": "@amir-yazdanmanesh", "displayName": "AmirMohammad Yazdanmanesh", "url": "https://github.com/amir-yazdanmanesh" },
{ "name": "@cumoon", "displayName": "C_U_Moon", "url": "https://github.com/cumoon" },
{ "name": "@hadi-norouzi", "displayName": "Hadi Norouzi", "url": "https://github.com/hadi-norouzi" },
{ "name": "Vahid Farid" }
]
},
{
"language": "Русский (Russian)",
"contributors": [
{ "name": "@solokot", "url": "https://github.com/solokot" },
{ "name": "@liniya", "url": "https://github.com/liniya" },
{ "name": "@eliotcougar", "url": "https://github.com/eliotcougar" }
]
},
{
"language": "Tiếng Việt (Vietnamese)",
"contributors": [
{ "name": "@admarty", "url": "https://github.com/admarty" },
{ "name": "@user09283", "url": "https://github.com/user09283" },
{ "name": "@yuhan6665", "url": "https://github.com/yuhan6665" }
]
},
{
"language": "简体中文 (Simplified Chinese)",
"contributors": [
{ "name": "@yau08", "displayName": "David", "url": "https://github.com/yau08" }
]
},
{
"language": "繁體中文 (Traditional Chinese)",
"contributors": [
{ "name": "@yau08", "displayName": "David", "url": "https://github.com/yau08" },
{ "name": "@fubuki0x10de", "url": "https://github.com/fubuki0x10de" }
]
}
]
+71 -84
View File
@@ -1,99 +1,86 @@
{
"stats":{},
"stats": {},
"log": {
"loglevel": "warning"
},
"policy":{
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
"policy": {
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [
{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"address": "v2ray.cool",
"port": 10086
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
}
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
"tag": "direct"
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
}
],
"outbounds": [{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "v2ray.cool",
"port": 10086,
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"alterId": 0,
"security": "auto",
"level": 8
}
]
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
],
"servers": [
{
"address": "v2ray.cool",
"method": "chacha20",
"ota": false,
"password": "123456",
"port": 10086,
"level": 8
}
]
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIP"
},
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": []
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
"hosts": {},
"servers": []
"hosts": {},
"servers": []
}
}
}
@@ -1,115 +1,103 @@
{
"stats":{},
"stats": {},
"log": {
"loglevel": "warning"
},
"policy":{
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
"policy": {
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [
{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
},
{
"tag": "tun",
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1500,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"address": "v2ray.cool",
"port": 10086
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
}
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
"tag": "direct"
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
},
{
"tag": "tun",
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1500,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
}
],
"outbounds": [{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "v2ray.cool",
"port": 10086,
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"alterId": 0,
"security": "auto",
"level": 8
}
]
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
],
"servers": [
{
"address": "v2ray.cool",
"method": "chacha20",
"ota": false,
"password": "123456",
"port": 10086,
"level": 8
}
]
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIP"
},
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": []
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
"hosts": {},
"servers": []
"hosts": {},
"servers": []
}
}
}
@@ -1,14 +1,17 @@
package com.v2ray.ang
import android.app.Application
import android.content.Context
import androidx.multidex.MultiDexApplication
import androidx.core.content.ContextCompat
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.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
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
}
@@ -18,7 +21,7 @@ class AngApplication : MultiDexApplication() {
* @param base The base context.
*/
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
super.attachBaseContext(base?.let(ContextCompat::getContextForLanguage))
application = this
}
@@ -32,17 +35,17 @@ class AngApplication : MultiDexApplication() {
override fun onCreate() {
super.onCreate()
MMKV.initialize(this)
MmkvManager.initialize(this)
AppLocaleManager.initialize(this)
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
// 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,19 +43,25 @@ 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"
const val PREF_APP_LOCALE_MIGRATED = "pref_app_locale_migrated"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_DYNAMIC_COLOR = "pref_dynamic_color"
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
const val PREF_ENABLE_LOCAL_PROXY = "pref_enable_local_proxy"
const val PREF_SOCKS_PORT = "pref_socks_port"
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
@@ -70,12 +76,16 @@ object AppConfig {
const val PREF_LOGLEVEL = "pref_core_loglevel"
const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method"
const val PREF_MODE = "pref_mode"
const val PREF_ROOT_MODE_ENABLE = "pref_root_mode_enabled"
const val PREF_ROOT_LAN_SHARING = "pref_root_lan_sharing"
const val PREF_IS_BOOTED = "pref_is_booted"
const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_UPDATE_SUBSCRIPTION = "pref_update_subscription"
const val PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION = "pref_auto_test_after_update_subscription"
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
@@ -105,7 +115,8 @@ object AppConfig {
const val TAG_FRAGMENT = "fragment"
const val TAG_DNS = "dns-module"
const val TAG_DOMESTIC_DNS = "domestic-dns"
const val TAG_BALANCER = "proxy-round"
const val TAG_BALANCER = "balancer-main"
const val TAG_BALANCER_PRE = "balancer"
/** Network-related constants. */
const val UPLINK = "uplink"
@@ -125,12 +136,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"
@@ -150,8 +166,16 @@ object AppConfig {
const val WIREGUARD_LOCAL_ADDRESS_V4 = "172.16.0.2/32"
const val WIREGUARD_LOCAL_ADDRESS_V6 = "2606:4700:110:8f81:d551:a0:532e:a2b3/128"
const val WIREGUARD_LOCAL_MTU = "1420"
const val WIREGUARD_LOCAL_REMOTE_DNS = "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001"
const val LOOPBACK = "127.0.0.1"
/** Shared defaults for settings shown in the UI and consumed by config generation. */
const val DEFAULT_SOCKS_ENABLE_UDP = true
const val DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD = "1"
const val DEFAULT_VPN_BYPASS_LAN = "1"
const val DEFAULT_HEV_TUNNEL_LOGLEVEL = "warn"
const val DEFAULT_MUX_XUDP_CONCURRENCY = "8"
/** Message constants for communication. */
const val MSG_REGISTER_CLIENT = 1
const val MSG_STATE_RUNNING = 11
@@ -164,22 +188,28 @@ object AppConfig {
const val MSG_STATE_STOP_SUCCESS = 41
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
const val MSG_MEASURE_DELAY_SUCCESS = 61
const val MSG_MEASURE_DELAY_RESULT = 61
const val MSG_MEASURE_DELAY_CANCEL = 62
const val MSG_MEASURE_CONFIG_START = 7
const val MSG_MEASURE_CONFIG_CANCEL = 71
const val MSG_MEASURE_CONFIG_SUCCESS = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
const val MSG_SUB_UPDATE_START = 8
const val MSG_SUB_UPDATE_CANCEL = 81
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
// 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"
/** Protocols Scheme **/
const val VMESS = "vmess://"
const val CUSTOM = ""
const val SHADOWSOCKS = "ss://"
const val SOCKS = "socks://"
const val SOCKS4 = "socks4://"
const val SOCKS5 = "socks5://"
const val HTTP = "http://"
const val VLESS = "vless://"
const val TROJAN = "trojan://"
@@ -188,11 +218,35 @@ 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_DNS_OUT_CHAIN = "CORE_DNS_OUT" // nat chain for device DNS DNAT (root mode)
const val ROOT_V6_CHAIN = "CORE6_FILTER" // ip6tables filter/OUTPUT chain: blackhole native IPv6 when it isn't tunneled
const val ROOT_V6_FWD_CHAIN = "CORE6_FWD" // ip6tables FORWARD chain: route or reject tethered clients' native IPv6
const val ROOT_V6_PRE_CHAIN = "CORE6_PRE" // ip6tables mangle/PREROUTING chain: mark forwarded clients' IPv6 into the tun
const val ROOT_LAN_DNS = "1.1.1.1" // fallback resolver for tethered clients when no plain-IPv4 DNS is configured
const val ROOT_OOM_SCORE = "-1000" // oom_score_adj that makes the LMK never kill us
/** hev-sock5-tunnel read-write-timeout value */
const val HEVTUN_RW_TIMEOUT = "300,60"
@@ -201,13 +255,19 @@ object AppConfig {
const val GOOGLEAPIS_COM_DOMAIN = "googleapis.com"
// Android Private DNS constants
const val DNS_DNSPOD_DOMAIN = "dot.pub"
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
const val DNS_CISCO_SSE_DOMAIN = "dns.sse.cisco.com"
const val DNS_CISCO_UMBRELLA_DOMAIN = "dns.umbrella.com"
const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
const val DNS_CLOUDFLARE_ONEDOT_DNS_DOMAIN = "1dot1dot1dot1.cloudflare-dns.com"
const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
const val DNS_CLOUDFLARE_WARP_DOMAIN = "engage.cloudflareclient.com"
const val DNS_DNSPOD_DOH_DOMAIN = "doh.pub"
const val DNS_DNSPOD_DOT_DOMAIN = "dot.pub"
const val DNS_GOOGLE_DOMAIN = "dns.google"
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
const val DNS_SB_DOMAIN = "dns.sb"
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
const val DEFAULT_PORT = 443
@@ -221,12 +281,18 @@ object AppConfig {
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
val DNS_CISCO_SSE_ADDRESSES = arrayListOf("208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53")
val DNS_CISCO_UMBRELLA_ADDRESSES = arrayListOf("208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53")
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
val DNS_CLOUDFLARE_ONEDOT_DNS_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("162.159.61.8", "172.64.41.8", "2a06:98c1:52::8", "2803:f800:53::8")
val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_CLOUDFLARE_WARP_ADDRESSES = arrayListOf("162.159.192.1", "2606:4700:d0::a29f:c001")
val DNS_DNSPOD_DOH_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_DNSPOD_DOT_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
val DNS_SB_ADDRESSES = arrayListOf("45.11.45.11", "185.222.222.222", "2a09::", "2a11::")
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
//minimum list https://serverfault.com/a/304791
@@ -285,4 +351,6 @@ object AppConfig {
TAG_DIRECT,
TAG_BLOCKED,
)
val OBSERVATORY_DURATION_PATTERN = Regex("""[1-9]\d*(ms|s|m|h)""")
}
@@ -1,32 +0,0 @@
package com.v2ray.ang.contracts
/**
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
* Extend this interface or define more specific interfaces for different adapters as needed.
*/
interface BaseAdapterListener {
/**
* Request the host to edit the specified item.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onEdit(guid: String, position: Int)
/**
* Request the host to remove the specified item. Position is provided for optional animation or validation.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onRemove(guid: String, position: Int)
/**
* Request the host to share the specified URL.
* @param url The URL to be shared
*/
fun onShare(url: String)
/**
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
*/
fun onRefreshData()
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.service
package com.v2ray.ang.contracts
import android.content.Context
@@ -1,13 +0,0 @@
package com.v2ray.ang.contracts
import com.v2ray.ang.dto.ProfileItem
interface MainAdapterListener : BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
fun onSelectServer(guid: String)
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
}
@@ -1,6 +1,7 @@
package com.v2ray.ang.contracts
import android.app.Service
import android.net.Network
interface ServiceControl {
/**
@@ -25,4 +26,13 @@ interface ServiceControl {
* @return True if the socket is protected, false otherwise.
*/
fun vpnProtect(socket: Int): Boolean
/**
* Declares the networks the tunnel runs on top of.
* Only meaningful for the VPN service, the other run modes have no interface to report.
*
* @param networks The upstream networks, null to let the system pick.
* @return True if the networks were accepted.
*/
fun setUnderlyingNetworks(networks: Array<Network>?): Boolean = false
}
@@ -3,9 +3,11 @@ package com.v2ray.ang.core
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BalancerStrategyType
import com.v2ray.ang.enums.CoreResolvedType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
@@ -13,58 +15,132 @@ import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
/**
* Builds [com.v2ray.ang.dto.CoreConfigContext] from the selected profile.
* Keeps parsing and resolution logic out of [CoreConfigManager].
* Build runtime context from the selected profile.
*
* All outbound type analysis is completed here for both the selected profile
* and routing targets. Custom profiles are returned immediately without
* entering the normal analysis flow.
*/
object CoreConfigContextBuilder {
/** Loads profile by guid and returns a resolved runtime context. */
/**
* Load one profile and produce a fully analyzed context.
*
* Null is returned only when the selected profile cannot be loaded.
*/
fun build(context: Context, guid: String): CoreConfigContext? {
val config = MmkvManager.decodeServerConfig(guid) ?: return null
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
if (config.configType == EConfigType.CUSTOM) {
return CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
resolvedProfiles = listOf(config),
resolvedType = CoreResolvedType.CUSTOM,
)
return CoreConfigContext(context = context, guid = guid, isCustom = true)
}
// Pre-resolve custom outbound profiles from routing rulesets
val customOutbounds = resolveCustomOutbounds()
// Step 1: Resolve the main outbound (always tag = TAG_PROXY).
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
return null
}
// Determine resolved profiles and type based on config type
val (resolvedProfiles, resolvedType) = when (config.configType) {
EConfigType.POLICYGROUP -> {
val profiles = resolvePolicyGroupProfiles(config)
Pair(profiles, CoreResolvedType.POLICYGROUP)
}
// Step 2: Resolve all non-builtin routing outbound tags.
val routingResolvedOutbounds = resolveRoutingOutbounds()
val resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds
val fallbackResolvedOutbounds = resolveFallbackOutbounds(resolvedOutbounds)
val routingDomainRules = collectRoutingDomainRulesForDns()
return CoreConfigContext(
context = context,
guid = guid,
resolvedOutbounds = resolvedOutbounds + fallbackResolvedOutbounds,
routingDomainRules = routingDomainRules,
)
}
/**
* Resolve one outbound target into a normalized outbound entry.
*
* Custom profiles are ignored at this stage and produce no entry.
*/
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
if (profile.configType == EConfigType.CUSTOM) {
return null
}
val (resolvedProfiles, resolvedType) = when (profile.configType) {
EConfigType.POLICYGROUP -> Pair(
resolvePolicyGroupProfiles(profile),
CoreResolvedType.POLICYGROUP,
)
EConfigType.PROXYCHAIN -> {
val profiles = resolveProxyChainProfiles(config)
Pair(profiles, CoreResolvedType.PROXYCHAIN)
val chainProfiles = resolveProxyChainProfiles(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
else -> {
val chainProfiles = resolveProxyChainProfilesFromGroup(config)
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
}
// Create context with common fields
return CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
return CoreConfigContext.ResolvedOutbound(
tag = tag,
profile = profile,
resolvedProfiles = resolvedProfiles,
resolvedType = resolvedType,
customOutboundProfiles = customOutbounds,
)
}
/** Resolves policy-group members with the same filters as runtime build. */
/**
* Collect and resolve non-builtin routing targets from enabled rules.
*
* Invalid or empty targets are skipped and handled by fallback logic later.
*/
private fun resolveRoutingOutbounds(): List<CoreConfigContext.ResolvedOutbound> {
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
val resolvedOutbounds = mutableListOf<CoreConfigContext.ResolvedOutbound>()
val processedTags = mutableSetOf<String>()
try {
rulesetItems
.filter { it.enabled }
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
.distinct()
.forEach { tag ->
if (tag in processedTags) {
return@forEach
}
processedTags.add(tag)
try {
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
LogUtil.w(AppConfig.TAG, "Routing tag '$tag' has no matching profile — will fall back to proxy at routing time")
return@forEach
}
val resolvedOutbound = resolveOutbound(tag, profile) ?: run {
LogUtil.w(AppConfig.TAG, "Cannot use CUSTOM profile as routing outbound for tag '$tag', skipping")
return@forEach
}
if (resolvedOutbound.resolvedProfiles.isEmpty()) {
LogUtil.w(AppConfig.TAG, "Routing outbound '$tag' resolved to empty list, skipping")
return@forEach
}
resolvedOutbounds.add(resolvedOutbound)
LogUtil.d(AppConfig.TAG, "Resolved routing outbound: tag='$tag', type='${resolvedOutbound.resolvedType}', profiles=${resolvedOutbound.resolvedProfiles.size}")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbound for tag '$tag', skipping", e)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbounds from rulesets", e)
}
return resolvedOutbounds
}
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
try {
val serverList = MmkvManager.decodeAllServerList()
@@ -92,18 +168,15 @@ object CoreConfigContextBuilder {
}
}
.filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { !it.configType.isComplexType() }
.toList()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for config '${config.remarks}'", e)
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
return listOf(config)
}
}
/** Resolves proxy-chain members with the same filters as runtime build. */
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
if (config.proxyChainProfiles.isNullOrBlank()) {
return listOf(config)
@@ -114,26 +187,22 @@ object CoreConfigContextBuilder {
.asSequence()
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
.filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { !it.configType.isComplexType() }
.toList()
.reversed()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for config '${config.remarks}'", e)
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolves chain nodes in fixed order: next -> current -> prev.
* If chain cannot be built, caller treats result as normal mode.
* Resolve chain nodes from subscription neighbors in order: next, current, prev.
*
* When no chain is available, return a single-node result.
*/
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
return listOf(config)
}
if (config.subscriptionId.isEmpty()) {
return listOf(config)
}
@@ -141,56 +210,64 @@ object CoreConfigContextBuilder {
try {
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
val resolved = mutableListOf<ProfileItem>()
// Keep the same practical chain order as current runtime assembly:
// next -> current -> prev
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
resolved.add(config)
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
return resolved
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles from group for config '${config.remarks}'", e)
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolves custom outbound profiles from routing rulesets.
* Scans rulesets for non-builtin outbound tags and looks up matching profiles by remarks.
* Returns a map of tag -> ProfileItem for independent processing.
* Collect enabled routing domain rules in original order for DNS segmentation.
*
* outbounds are normalized into three tags only: proxy / direct / block.
*/
private fun resolveCustomOutbounds(): Map<String, ProfileItem> {
val customMap = mutableMapOf<String, ProfileItem>()
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return customMap
private fun collectRoutingDomainRulesForDns(): List<CoreConfigContext.RoutingDomainRule> {
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
val result = mutableListOf<CoreConfigContext.RoutingDomainRule>()
try {
val processedTags = mutableSetOf<String>()
rulesetItems
.filter { it.enabled }
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
.distinct()
.forEach { tag ->
if (tag in processedTags) return@forEach
processedTags.add(tag)
try {
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
return@forEach
}
customMap[tag] = profile
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound for tag '$tag', skipping", e)
}
rulesetItems
.asSequence()
.filter { it.enabled }
.filter { !it.domain.isNullOrEmpty() }
.forEach { rule ->
val normalizedOutboundTag = when (rule.outboundTag) {
AppConfig.TAG_DIRECT -> AppConfig.TAG_DIRECT
AppConfig.TAG_BLOCKED -> AppConfig.TAG_BLOCKED
else -> AppConfig.TAG_PROXY
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound profiles", e)
}
result.add(
CoreConfigContext.RoutingDomainRule(
domain = rule.domain.orEmpty(),
outboundTag = normalizedOutboundTag
)
)
}
return customMap
return result
}
/**
* Resolve and collect fallback outbounds from all POLICYGROUP nodes.
*
* Fallback targets must not overlap with already resolved tags or builtin tags.
*/
private fun resolveFallbackOutbounds(resolvedOutbounds: List<CoreConfigContext.ResolvedOutbound>): List<CoreConfigContext.ResolvedOutbound> {
return resolvedOutbounds
.asSequence()
.filter { it.resolvedType == CoreResolvedType.POLICYGROUP }
.filter { BalancerStrategyType.from(it.profile.policyGroupType).supportsObservatory && it.profile.policyGroupTestOutbounds != false }
.mapNotNull { it.profile.policyGroupFallbackTag }
.filter { it !in AppConfig.BUILTIN_OUTBOUND_TAGS && resolvedOutbounds.none { outbound -> outbound.tag == it } }
.distinct()
.mapNotNull { tag ->
SettingsManager.getServerViaRemarks(tag)
?.takeUnless { it.configType == EConfigType.CUSTOM || it.configType == EConfigType.POLICYGROUP }
?.let { resolveOutbound(tag, it) }
}
.toList()
}
}
File diff suppressed because it is too large. Load diff
@@ -1,11 +1,10 @@
package com.v2ray.ang.core
import android.text.TextUtils
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty
@@ -63,9 +62,9 @@ object CoreOutboundBuilder {
if (muxEnabled) {
outbound.mux?.enabled = true
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY).orEmpty().toInt()
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
outbound.mux?.concurrency = -1
}
} else {
@@ -73,41 +72,6 @@ object CoreOutboundBuilder {
outbound.mux?.concurrency = -1
}
if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
var localTunAddr = if (outbound.settings?.address == null) {
listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
} else {
outbound.settings?.address as List<*>
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) != true) {
localTunAddr = listOf(localTunAddr.first())
}
outbound.settings?.address = localTunAddr
}
if (outbound.streamSettings?.network == AppConfig.DEFAULT_NETWORK
&& outbound.streamSettings?.tcpSettings?.header?.type == AppConfig.HEADER_TYPE_HTTP
) {
val path = outbound.streamSettings?.tcpSettings?.header?.request?.path
val host = outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host
val requestString: String by lazy {
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":["no-cache"]}}"""
}
outbound.streamSettings?.tcpSettings?.header?.request = JsonUtil.fromJson(
requestString,
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
)
outbound.streamSettings?.tcpSettings?.header?.request?.path =
if (path.isNullOrEmpty()) {
listOf("/")
} else {
path
}
outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host = host
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
return false
@@ -119,49 +83,30 @@ object CoreOutboundBuilder {
fun createInitOutbound(configType: EConfigType): OutboundBean? {
return when (configType) {
EConfigType.VMESS,
EConfigType.VLESS ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
vnext = listOf(
OutboundBean.OutSettingsBean.VnextBean(
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
)
)
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.VLESS,
EConfigType.SHADOWSOCKS,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.TROJAN ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.TROJAN -> OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.WIREGUARD ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
secretKey = "",
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
)
EConfigType.WIREGUARD -> OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
secretKey = "",
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
)
)
EConfigType.HYSTERIA,
EConfigType.HYSTERIA2 ->
return OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
servers = null
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.HYSTERIA2 -> OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutboundBean.OutSettingsBean(),
streamSettings = OutboundBean.StreamSettingsBean()
)
else -> null
}
@@ -172,11 +117,12 @@ object CoreOutboundBuilder {
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].security = profileItem.method
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.id = profileItem.password.orEmpty()
settings.security = profileItem.method
settings.level = AppConfig.DEFAULT_LEVEL
}
val sni = outboundBean?.streamSettings?.let {
@@ -193,12 +139,13 @@ object CoreOutboundBuilder {
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].encryption = profileItem.method
vnext.users[0].flow = profileItem.flow
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.id = profileItem.password.orEmpty()
settings.encryption = profileItem.method
settings.flow = profileItem.flow
settings.level = AppConfig.DEFAULT_LEVEL
}
val sni = outboundBean?.streamSettings?.let {
@@ -215,11 +162,12 @@ object CoreOutboundBuilder {
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.method = profileItem.method
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.password = profileItem.password
settings.method = profileItem.method
settings.level = AppConfig.DEFAULT_LEVEL
}
val sni = outboundBean?.streamSettings?.let {
@@ -236,11 +184,12 @@ object CoreOutboundBuilder {
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.flow = profileItem.flow
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.password = profileItem.password
settings.flow = profileItem.flow
settings.level = AppConfig.DEFAULT_LEVEL
}
val sni = outboundBean?.streamSettings?.let {
@@ -257,14 +206,13 @@ object CoreOutboundBuilder {
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.level = AppConfig.DEFAULT_LEVEL
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
settings.user = profileItem.username.orEmpty()
settings.pass = profileItem.password.orEmpty()
}
}
@@ -274,14 +222,13 @@ object CoreOutboundBuilder {
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
outboundBean?.settings?.let { settings ->
settings.address = getServerAddress(profileItem)
settings.port = profileItem.serverPort.orEmpty().toInt()
settings.level = AppConfig.DEFAULT_LEVEL
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
settings.user = profileItem.username.orEmpty()
settings.pass = profileItem.password.orEmpty()
}
}
@@ -291,18 +238,55 @@ object CoreOutboundBuilder {
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
val rawAddresses = profileItem.localAddress
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.ifEmpty { null }
?: listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
val addresses = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
rawAddresses
} else {
val ipv4Addresses = rawAddresses.filter { !it.contains(":") }
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
}
val rawDNS = profileItem.remoteDNS
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.ifEmpty { null }
?: listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS)
val remotes = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
rawDNS
} else {
val ipv4Dns = rawDNS.filter { !it.contains(":") }
ipv4Dns.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS) }
}
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = (profileItem.localAddress ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4).split(",")
wireguard.address = addresses
wireguard.port = null
wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
}
wireguard.mtu = profileItem.mtu
wireguard.remoteDNS = remotes
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
if (!profileItem.finalMask.isNullOrBlank()) {
outboundBean?.streamSettings = OutboundBean.StreamSettingsBean()
outboundBean?.streamSettings?.let {
updateOutboundFinalMask(it, profileItem)
it.network = null
}
}
return outboundBean
}
@@ -328,6 +312,23 @@ object CoreOutboundBuilder {
return outboundBean
}
private fun createTcpHttpRequest(
host: String?,
path: String?
): OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean {
val requestString =
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
val request = JsonUtil.fromJson(
requestString,
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
) ?: OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
val parsedHost = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
request.headers.Host = parsedHost.ifEmpty { null }
request.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("/") }
return request
}
/**
* Configures transport settings for an outbound connection.
*
@@ -358,13 +359,9 @@ object CoreOutboundBuilder {
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
val requestObj = OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
requestObj.headers.Host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
requestObj.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
tcpSetting.header.request = requestObj
sni = requestObj.headers.Host?.getOrNull(0)
}
val requestObj = createTcpHttpRequest(host, path)
tcpSetting.header.request = requestObj
sni = requestObj.headers.Host?.getOrNull(0)
} else {
tcpSetting.header.type = "none"
sni = host
@@ -377,41 +374,45 @@ 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
)
)
)
}
udpMaskList.reverse()
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
udp = udpMaskList.toList()
)
@@ -419,7 +420,7 @@ object CoreOutboundBuilder {
NetworkType.WS.type -> {
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
wssetting.headers.Host = host.orEmpty()
wssetting.host = host.orEmpty()
sni = host
wssetting.path = path ?: "/"
streamSettings.wsSettings = wssetting
@@ -554,7 +555,7 @@ object CoreOutboundBuilder {
*/
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
val streamSecurity = profileItem.security.orEmpty()
val allowInsecure = profileItem.insecure == true
val allowInsecure = profileItem.insecure == true && profileItem.pinnedCA256.isNullOrEmpty()
val sni = if (profileItem.sni.isNullOrEmpty()) {
when {
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
@@ -573,6 +574,7 @@ object CoreOutboundBuilder {
fingerprint = profileItem.fingerPrint.nullIfBlank(),
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
echConfigList = profileItem.echConfigList.nullIfBlank(),
verifyPeerCertByName = profileItem.verifyPeerCertByName.nullIfBlank(),
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
publicKey = profileItem.publicKey.nullIfBlank(),
shortId = profileItem.shortId.nullIfBlank(),
@@ -620,10 +622,6 @@ object CoreOutboundBuilder {
&& packets == "tlshello"
) {
packets = "1-3"
} else if (streamSettings.security == AppConfig.TLS
&& packets != "tlshello"
) {
packets = "tlshello"
}
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
@@ -633,7 +631,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(
@@ -652,8 +652,7 @@ object CoreOutboundBuilder {
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
} ?: JsonObject()
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
fun appendMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
val current = finalMaskObj.get(scope)
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
return
@@ -668,8 +667,8 @@ object CoreOutboundBuilder {
finalMaskObj.add(scope, newArray)
}
prependMask("tcp", fragmentMask)
prependMask("udp", noiseMask)
appendMask("tcp", fragmentMask)
appendMask("udp", noiseMask)
streamSettings.finalmask = finalMaskObj
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
@@ -684,7 +683,7 @@ object CoreOutboundBuilder {
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "2") {
return domain
}
//Resolve and replace domain
@@ -694,4 +693,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")
}
}
}
}
@@ -1,5 +1,6 @@
package com.v2ray.ang.core
import android.app.Activity
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
@@ -11,26 +12,32 @@ import android.os.ParcelFileDescriptor
import android.system.OsConstants
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.IDialerService
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.OutboundTrafficStat
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BrowserDialerMode
import com.v2ray.ang.extension.delay
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.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.ProcessFinder
@@ -44,6 +51,14 @@ object CoreServiceManager {
private var currentConfig: ProfileItem? = null
private var processFinder: XrayProcessFinder? = null
private var browserDialer: IDialerService? = null
private var networkMonitor: NetworkMonitor? = null
private val connectionTestScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var isReloading = false
/** Tun descriptor the core was started with, null in the proxy only and root run modes. */
private var currentVpnInterface: ParcelFileDescriptor? = null
var serviceControl: SoftReference<ServiceControl>? = null
set(value) {
@@ -56,55 +71,6 @@ object CoreServiceManager {
}
}
/**
* Starts the V2Ray service from a toggle action.
* @param context The context from which the service is started.
* @return True if the service was started successfully, false otherwise.
*/
fun startVServiceFromToggle(context: Context): Boolean {
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
context.toast(R.string.app_tile_first_use)
return false
}
try {
startContextService(context)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
context.toast(e.message ?: e.javaClass.simpleName)
return false
}
return true
}
/**
* Starts the V2Ray service.
* @param context The context from which the service is started.
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) {
MmkvManager.setSelectServer(guid)
}
try {
startContextService(context)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
context.toast(e.message ?: e.javaClass.simpleName)
}
}
/**
* Stops the V2Ray service.
* @param context The context from which the service is stopped.
*/
fun stopVService(context: Context) {
//context.toast(R.string.toast_services_stop)
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
}
/**
* Checks if the V2Ray service is running.
* @return True if the service is running, false otherwise.
@@ -117,74 +83,13 @@ object CoreServiceManager {
*/
fun getRunningServerName() = currentConfig?.remarks.orEmpty()
/**
* Starts the context service for V2Ray.
* Chooses between VPN service or Proxy-only service based on user settings.
* @param context The context from which the service is started.
* @throws IllegalStateException if the core is already running, no server is selected,
* server config cannot be decoded, or server configuration is invalid.
* @throws Exception if the foreground service fails to start.
*/
@Throws(Exception::class)
private fun startContextService(context: Context) {
if (coreController.isRunning) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return
}
val guid = MmkvManager.getSelectServer()
?: run {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
error(context.getString(R.string.app_tile_first_use))
}
val config = MmkvManager.decodeServerConfig(guid)
?: run {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
error(context.getString(R.string.toast_config_file_invalid))
}
if (config.configType != EConfigType.CUSTOM
&& config.configType != EConfigType.POLICYGROUP
&& config.configType != EConfigType.PROXYCHAIN
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
error(context.getString(R.string.toast_config_file_invalid))
}
// refresh socks port when enabled dynamic socks port
SettingsManager.refreshRuntimeSocksPort()
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
context.toast(R.string.toast_warning_pref_proxysharing_short)
} else {
context.toast(R.string.toast_services_start)
}
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, CoreVpnService::class.java)
} else {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
}
ContextCompat.startForegroundService(context, intent)
}
/**
* Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
* `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
* Starts the V2Ray core service.
*/
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
if (coreController.isRunning) {
if (isRunning()) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false
}
@@ -201,7 +106,7 @@ object CoreServiceManager {
} catch (e: Exception) {
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
LogUtil.e(AppConfig.TAG, "StartCore-Manager: $message", e)
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
NotificationManager.cancelNotification()
return false
}
@@ -209,6 +114,19 @@ object CoreServiceManager {
@Throws(Exception::class)
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
currentVpnInterface = vpnInterface
launchCore(service, vpnInterface)
startNetworkMonitor(service)
}
@Throws(Exception::class)
private fun launchCore(service: Service, vpnInterface: ParcelFileDescriptor?, isReload: Boolean = false) {
val guid = MmkvManager.getSelectServer() ?: error("No server selected")
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
@@ -219,28 +137,25 @@ object CoreServiceManager {
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
}
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
currentConfig = config
var tunFd = vpnInterface?.fd ?: 0
val dialerAddr = if (currentConfig?.browserDialerMode.isNullOrEmpty()) {
""
} else {
val dialerMode = BrowserDialerMode.from(config.browserDialerMode)
val dialerAddr = if (dialerMode != null) {
"127.0.0.1:${Utils.findRandomFreePort()}"
} else {
""
}
if (SettingsManager.isUsingHevTun()) {
tunFd = 0
}
NotificationManager.showNotification(currentConfig)
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
if (dialerAddr.isNotNullEmpty()) {
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
}
coreController.startLoop(result.content, tunFd)
if (!coreController.isRunning) {
if (!isRunning()) {
error("Core failed to start")
}
@@ -248,16 +163,24 @@ object CoreServiceManager {
browserDialer!!.stop()
browserDialer = null
}
if (config.browserDialerMode == "OkHttp") {
browserDialer = DialerNativeService()
browserDialer!!.start(service, dialerAddr)
} else if (config.browserDialerMode == "WebView") {
browserDialer = DialerWebviewService()
browserDialer!!.start(service, dialerAddr)
when (dialerMode) {
BrowserDialerMode.OKHTTP -> {
browserDialer = DialerNativeService()
browserDialer!!.start(service, dialerAddr)
}
BrowserDialerMode.WEBVIEW -> {
browserDialer = DialerWebviewService()
browserDialer!!.start(service, dialerAddr)
}
else -> {}
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.startSpeedNotification(currentConfig)
if (!isReload) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
}
NotificationManager.startSpeedNotification()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
}
@@ -267,9 +190,14 @@ object CoreServiceManager {
* @return True if the core was stopped successfully, false otherwise.
*/
fun stopCoreLoop(): Boolean {
connectionTestScope.coroutineContext.cancelChildren()
val service = getService() ?: return false
if (coreController.isRunning) {
networkMonitor?.unregister()
networkMonitor = null
currentVpnInterface = null
if (isRunning()) {
CoroutineScope(Dispatchers.IO).launch {
try {
coreController.stopLoop()
@@ -286,7 +214,7 @@ object CoreServiceManager {
browserDialer = null
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
NotificationManager.cancelNotification()
try {
@@ -299,13 +227,88 @@ object CoreServiceManager {
}
/**
* Queries the statistics for a given tag and link.
* @param tag The tag to query.
* @param link The link to query.
* @return The statistics value.
* Subscribes to upstream network changes for whichever run mode is active.
* All three services share this manager, so the tunnel recovers from a handover in proxy only
* and root mode as well, not just behind the VPN interface.
*/
fun queryStats(tag: String, link: String): Long {
return coreController.queryStats(tag, link)
private fun startNetworkMonitor(service: Service) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
if (networkMonitor != null) return
val connectivity = service.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
networkMonitor = NetworkMonitor(
connectivity = connectivity,
onUnderlyingNetworksChanged = { networks -> serviceControl?.get()?.setUnderlyingNetworks(networks) },
onHandover = { reloadCore() },
).also { it.register() }
}
/**
* Restarts the core in place after the upstream network changed: the service, the notification
* and the VPN interface all stay up, so nothing of this is visible.
*
* The config is rebuilt on purpose, outbound server domains are resolved while building it and
* an address resolved on a network that is gone can be unusable on the new one.
*
* @return True if the core is running again.
*/
private fun reloadCore(): Boolean {
if (isReloading) return false
val service = getService() ?: return false
if (!isRunning()) return false
return try {
val tunFd = currentVpnInterface
isReloading = true
connectionTestScope.coroutineContext.cancelChildren()
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>()
payload.split(';').forEach { entry ->
if (entry.isBlank()) return@forEach
val parts = entry.split(',', limit = 3)
if (parts.size != 3) return@forEach
val value = parts[2].toLongOrNull() ?: return@forEach
result.add(
OutboundTrafficStat(
tag = parts[0],
direction = parts[1],
value = value,
)
)
}
// LogUtil.d(AppConfig.TAG, "Queried outbound traffic stats: $result")
return result
}
/**
@@ -313,13 +316,15 @@ object CoreServiceManager {
* Tests with primary URL first, then falls back to alternative URL if needed.
* Also fetches remote IP information if the delay test was successful.
*/
private fun measureV2rayDelay() {
if (coreController.isRunning == false) {
private fun measureV2rayDelay(requestId: String) {
val service = getService() ?: return
if (!isRunning() || isReloading) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
return
}
CoroutineScope(Dispatchers.IO).launch {
val service = getService() ?: return@launch
connectionTestScope.coroutineContext.cancelChildren()
connectionTestScope.launch {
var time = -1L
var errorStr = ""
@@ -327,30 +332,37 @@ object CoreServiceManager {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
errorStr = e.message?.substringAfter("\":").orEmpty()
}
if (time == -1L) {
ensureActive()
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
errorStr = e.message?.substringAfter("\":").orEmpty()
}
}
val result = if (time >= 0) {
service.getString(R.string.connection_test_available, time)
} else {
service.getString(R.string.connection_test_error, errorStr)
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
// Only fetch IP info if the delay test was successful
if (time >= 0) {
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
ensureActive()
val endpoint = if (time >= 0) SpeedtestManager.getRemoteIPInfo() else null
val result = ConnectionTestResult(
delayMillis = time,
errorMessage = errorStr,
country = endpoint?.country,
ipAddress = endpoint?.ipAddress,
)
withContext(Dispatchers.Main.immediate) {
if (isRunning()) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result, requestId)
} else {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
}
}
}.invokeOnCompletion { cause ->
if (cause is CancellationException) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
}
}
}
@@ -372,6 +384,7 @@ object CoreServiceManager {
* @return 0 for success, any other value for failure.
*/
override fun startup(): Long {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback startup")
return 0
}
@@ -380,14 +393,8 @@ object CoreServiceManager {
* @return 0 for success, any other value for failure.
*/
override fun shutdown(): Long {
val serviceControl = serviceControl?.get() ?: return -1
return try {
serviceControl.stopService()
0
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
-1
}
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback shutdown")
return 0
}
/**
@@ -397,6 +404,7 @@ object CoreServiceManager {
* @return Always returns 0.
*/
override fun onEmitStatus(l: Long, s: String?): Long {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback onEmitStatus $s")
return 0
}
}
@@ -453,10 +461,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, "")
}
}
@@ -475,27 +483,39 @@ object CoreServiceManager {
AppConfig.MSG_STATE_RESTART -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService()
Thread.sleep(500L)
startVService(serviceControl.getService())
// The UI and daemon run in separate processes, so acknowledge the active
// daemon before stopping it instead of relying on possibly stale UI state.
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
val pendingResult = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
serviceControl.stopService()
delay(500L)
LauncherManager.startService(serviceControl.getService())
} finally {
pendingResult.finish()
}
}
}
AppConfig.MSG_MEASURE_DELAY -> {
measureV2rayDelay()
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
measureV2rayDelay(intent.getStringExtra("content").orEmpty())
}
}
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification(currentConfig)
NotificationManager.stopSpeedNotification()
}
Intent.ACTION_SCREEN_ON -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification(currentConfig)
NotificationManager.startSpeedNotification()
}
}
}
}
}
}
@@ -0,0 +1,141 @@
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, "")
}
/** Restarts the active daemon without starting a stopped service. */
fun restartService(context: Context) {
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_RESTART, "")
}
/** Restarts the active daemon, or delegates to the caller's permission-aware start flow. */
fun restartServiceOrStart(context: Context, startIfStopped: () -> Unit) {
MessageHelper.sendMsg2ServiceForResult(context, AppConfig.MSG_STATE_RESTART, "") { handled ->
if (!handled) startIfStopped()
}
}
@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
}
}
}
@@ -1,11 +1,8 @@
package com.v2ray.ang.dto
import android.graphics.drawable.Drawable
data class AppInfo(
val appName: String,
val packageName: String,
val appIcon: Drawable,
val isSystemApp: Boolean,
var isSelected: Int
)
@@ -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 = "",
)
@@ -0,0 +1,11 @@
package com.v2ray.ang.dto
import java.io.Serializable
/** Locale-neutral result sent from the daemon for presentation by the UI process. */
data class ConnectionTestResult(
val delayMillis: Long,
val errorMessage: String = "",
val country: String? = null,
val ipAddress: String? = null,
) : Serializable
@@ -1,16 +1,25 @@
package com.v2ray.ang.dto
import android.content.Context
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.CoreResolvedType
/**
* Runtime context produced by the builder and consumed by CoreConfigManager.
*/
data class CoreConfigContext(
val context: Context,
val guid: String,
val selectedProfile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
val customOutboundProfiles: Map<String, ProfileItem> = emptyMap(),
)
val isCustom: Boolean = false,
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
val routingDomainRules: List<RoutingDomainRule> = emptyList(),
) {
data class ResolvedOutbound(
val tag: String,
val profile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
)
data class RoutingDomainRule(
val domain: List<String>,
val outboundTag: String,
)
}
@@ -1,6 +1,9 @@
package com.v2ray.ang.dto
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
data class GroupMapItem(
var id: String,
var remarks: String,
val usageInfo: SubscriptionUsageInfo? = null,
)
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class LocateTarget(
val groupId: String,
val serverGuid: String,
)
@@ -0,0 +1,7 @@
package com.v2ray.ang.dto
data class OutboundTrafficStat(
val tag: String,
val direction: String,
val value: Long,
)
@@ -1,138 +0,0 @@
package com.v2ray.ang.dto
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.AppConfig.PORT_SOCKS
import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils
data class ProfileItem(
val configVersion: Int = 4,
val configType: EConfigType,
var subscriptionId: String = "",
var addedTime: Long = System.currentTimeMillis(),
var remarks: String = "",
var description: String? = null,
var server: String? = null,
var serverPort: String? = null,
var password: String? = null,
var method: String? = null,
var flow: String? = null,
var username: String? = null,
var network: String? = null,
var headerType: String? = null,
var host: String? = null,
var path: String? = null,
var seed: String? = null,
var kcpMtu: Int? = null,
var kcpTti: Int? = null,
var quicSecurity: String? = null,
var quicKey: String? = null,
var mode: String? = null,
var serviceName: String? = null,
var authority: String? = null,
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null,
var sni: String? = null,
var alpn: String? = null,
var fingerPrint: String? = null,
var insecure: Boolean? = null,
var echConfigList: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null,
var shortId: String? = null,
var spiderX: String? = null,
var mldsa65Verify: String? = null,
var secretKey: String? = null,
var preSharedKey: String? = null,
var localAddress: String? = null,
var reserved: String? = null,
var mtu: Int? = null,
var obfsPassword: String? = null,
var portHopping: String? = null,
var portHoppingInterval: String? = null,
var pinSHA256: String? = null,
var bandwidthDown: String? = null,
var bandwidthUp: String? = null,
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
var proxyChainProfiles: String? = null,
var browserDialerMode: String? = null,
) {
companion object {
fun create(configType: EConfigType): ProfileItem {
return ProfileItem(configType = configType)
}
}
fun getAllOutboundTags(): MutableList<String> {
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
}
fun getServerAddressAndPort(): String {
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
return "$LOOPBACK:$PORT_SOCKS"
}
return Utils.getIpv6Address(server) + ":" + serverPort
}
override fun equals(other: Any?): Boolean {
if (other == null) return false
val obj = other as ProfileItem
return (this.server == obj.server
&& this.serverPort == obj.serverPort
&& this.password == obj.password
&& this.method == obj.method
&& this.flow == obj.flow
&& this.username == obj.username
&& this.network == obj.network
&& this.headerType == obj.headerType
&& this.host == obj.host
&& this.path == obj.path
&& this.seed == obj.seed
&& this.kcpMtu == obj.kcpMtu
&& this.kcpTti == obj.kcpTti
&& this.quicSecurity == obj.quicSecurity
&& this.quicKey == obj.quicKey
&& this.mode == obj.mode
&& this.serviceName == obj.serviceName
&& this.authority == obj.authority
&& this.xhttpMode == obj.xhttpMode
&& this.security == obj.security
&& this.sni == obj.sni
&& this.alpn == obj.alpn
&& this.fingerPrint == obj.fingerPrint
&& this.publicKey == obj.publicKey
&& this.shortId == obj.shortId
&& this.secretKey == obj.secretKey
&& this.localAddress == obj.localAddress
&& this.reserved == obj.reserved
&& this.mtu == obj.mtu
&& this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinnedCA256 == obj.pinnedCA256
&& this.proxyChainProfiles == obj.proxyChainProfiles
)
}
}
@@ -0,0 +1,9 @@
package com.v2ray.ang.dto
import java.io.Serializable
/** One persisted delay-test result delivered from the task process to the UI. */
data class RealPingResult(
val guid: String,
val delayMillis: Long,
) : Serializable
@@ -1,10 +0,0 @@
package com.v2ray.ang.dto
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
fun getTestDelayString(): String {
if (testDelayMillis == 0L) {
return ""
}
return testDelayMillis.toString() + "ms"
}
}
@@ -1,6 +0,0 @@
package com.v2ray.ang.dto
data class ServersCache(
val guid: String,
val profile: ProfileItem
)
@@ -0,0 +1,9 @@
package com.v2ray.ang.dto
import java.io.Serializable
data class SubscriptionUpdateMessage(
val key: Int,
val forcedUpdate: Boolean,
val subIds: List<String> = listOf()
) : Serializable
@@ -5,6 +5,7 @@ import java.io.Serializable
data class TestServiceMessage(
val key: Int,
val subscriptionId: String = "",
val serverGuids: List<String> = emptyList()
val serverGuids: List<String> = emptyList(),
val onlyTcp: Boolean = false
) : Serializable
@@ -0,0 +1,36 @@
package com.v2ray.ang.dto
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
data class Contributor(
val name: String,
val displayName: String? = null,
val url: String? = null
)
data class TranslatorsCredit(
val language: String,
val contributors: List<Contributor>
)
object TranslatorsParser {
private val gson = Gson()
fun parse(json: String): List<TranslatorsCredit> {
if (json.isBlank()) return emptyList()
return try {
val type = object : TypeToken<List<TranslatorsCredit>>() {}.type
gson.fromJson<List<TranslatorsCredit>>(json, type).orEmpty()
.filter { it.language.isNotBlank() && it.contributors.isNotEmpty() }
.distinctBy { it.language }
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse translators.json", e)
emptyList()
}
}
}
@@ -0,0 +1,11 @@
package com.v2ray.ang.dto
data class UrlContentRequest(
val url: String?,
val timeout: Int = 15000,
val httpPort: Int = 0,
val proxyUsername: String? = null,
val proxyPassword: String? = null,
val userAgent: String? = null,
val requestHeaders: String? = null
)
@@ -68,71 +68,39 @@ data class V2rayConfig(
var protocol: String,
var settings: OutSettingsBean? = null,
var streamSettings: StreamSettingsBean? = null,
val proxySettings: Any? = null,
val sendThrough: String? = null,
var mux: MuxBean? = MuxBean(false)
var mux: MuxBean? = MuxBean(false),
var targetStrategy: String? = null
) {
data class OutSettingsBean(
var vnext: List<VnextBean>? = null,
var servers: List<ServersBean>? = null,
/*Blackhole*/
var response: Response? = null,
/*DNS*/
val network: String? = null,
/*Common */
var address: Any? = null,
var port: Int? = null,
/*Freedom*/
var domainStrategy: String? = null,
val redirect: String? = null,
val userLevel: Int? = null,
/*Loopback*/
val inboundTag: String? = null,
var level: Int? = null,
var email: String? = null,
/*HTTP/SOCKS*/
var user: String? = null,
var pass: String? = null,
var headers: Map<String, String>? = null,
/*VMess/VLESS*/
var id: String? = null,
var security: String? = null,
var encryption: String? = null,
/*VLESS*/
var flow: String? = null,
/*Trojan/Shadowsocks*/
var password: String? = null,
/*Shadowsocks*/
var method: String? = null,
/*Hysteria/Hysteria2*/
var version: Int? = null,
/*Wireguard*/
var secretKey: String? = null,
val peers: List<WireGuardBean>? = null,
var reserved: List<Int>? = null,
var mtu: Int? = null,
var obfsPassword: String? = null,
var version: Int? = null,
var remoteDNS: List<String>? = null,
) {
data class VnextBean(
var address: String = "",
var port: Int = AppConfig.DEFAULT_PORT,
var users: List<UsersBean>
) {
data class UsersBean(
var id: String = "",
var alterId: Int? = null,
var security: String? = null,
var level: Int = AppConfig.DEFAULT_LEVEL,
var encryption: String? = null,
var flow: String? = null
)
}
data class ServersBean(
var address: String = "",
var method: String? = null,
var ota: Boolean = false,
var password: String? = null,
var port: Int = AppConfig.DEFAULT_PORT,
var level: Int = AppConfig.DEFAULT_LEVEL,
val email: String? = null,
var flow: String? = null,
val ivCheck: Boolean? = null,
var users: List<SocksUsersBean>? = null
) {
data class SocksUsersBean(
var user: String = "",
var pass: String = "",
var level: Int = AppConfig.DEFAULT_LEVEL
)
}
data class Response(var type: String)
data class WireGuardBean(
var publicKey: String = "",
var preSharedKey: String? = null,
@@ -141,7 +109,7 @@ data class V2rayConfig(
}
data class StreamSettingsBean(
var network: String = AppConfig.DEFAULT_NETWORK,
var network: String? = AppConfig.DEFAULT_NETWORK,
var security: String? = null,
var tcpSettings: TcpSettingsBean? = null,
var kcpSettings: KcpSettingsBean? = null,
@@ -181,7 +149,7 @@ data class V2rayConfig(
@SerializedName("Accept-Encoding")
val acceptEncoding: List<String>? = null,
val Connection: List<String>? = null,
val Pragma: String? = null
val Pragma: Any? = null
)
}
}
@@ -199,13 +167,12 @@ data class V2rayConfig(
data class WsSettingsBean(
var path: String? = null,
var headers: HeadersBean = HeadersBean(),
var host: String? = null,
var headers: Map<String, String>? = null,
val maxEarlyData: Int? = null,
val useBrowserForwarding: Boolean? = null,
val acceptProxyProtocol: Boolean? = null
) {
data class HeadersBean(var Host: String = "")
}
)
data class HttpupgradeSettingsBean(
var path: String? = null,
@@ -256,9 +223,9 @@ data class V2rayConfig(
val disableSystemRoot: Boolean? = null,
val enableSessionResumption: Boolean? = null,
var echConfigList: String? = null,
var verifyPeerCertByName: String? = null,
var pinnedPeerCertSha256: String? = null,
// REALITY settings
val show: Boolean = false,
var publicKey: String? = null,
var shortId: String? = null,
var spiderX: String? = null,
@@ -298,12 +265,13 @@ data class V2rayConfig(
) {
data class MaskSettingsBean(
val password: String? = null,
val domain: String? = null,
val header: String? = null,
val value: String? = null,
// fragment
val packets: String? = null,
val length: String? = null,
val delay: String? = null,
// val maxSplit: String? = null,
val maxSplit: String? = null,
// noise
val reset: Int? = null,
val noise: List<NoiseMaskBean>? = null
@@ -341,45 +309,19 @@ data class V2rayConfig(
)
fun getServerAddress(): String? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) {
return settings?.vnext?.firstOrNull()?.address ?: settings?.address as? String
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
) {
return settings?.servers?.firstOrNull()?.address
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.address as? String
return if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
} else {
settings?.address as? String
}
return null
}
fun getServerPort(): Int? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) {
return settings?.vnext?.firstOrNull()?.port ?: settings?.port
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
) {
return settings?.servers?.firstOrNull()?.port
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toInt()
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.port
return if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toIntOrNull()
} else {
settings?.port
}
return null
}
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
@@ -496,6 +438,7 @@ data class V2rayConfig(
data class PingConfigObject(
val destination: String,
val connectivity: String? = null,
val httpMethod: String? = null,
val interval: String,
val sampling: Int,
val timeout: String? = null
@@ -0,0 +1,168 @@
package com.v2ray.ang.dto
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BalancerStrategyType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
@Suppress("PropertyName")
data class V2rayNShareItem(
val IndexId: String?,
val ConfigType: Int?,
// val CoreType: Int?,
val ConfigVersion: Int?,
val Subid: String?,
val IsSub: Boolean?,
val PreSocksPort: Int?,
// val DisplayLog: Boolean?,
val Remarks: String?,
val Address: String?,
val Port: Int?,
val Password: String?,
val Username: String?,
val Network: String?,
val StreamSecurity: String?,
val AllowInsecure: String?,
val Sni: String?,
val Alpn: String?,
val Fingerprint: String?,
val PublicKey: String?,
val ShortId: String?,
val SpiderX: String?,
val Mldsa65Verify: String?,
// val MuxEnabled: Boolean?,
// val Cert: String?,
val CertSha: String?,
val EchConfigList: String?,
val VerifyPeerCertByName: String?,
val Finalmask: String?,
val ProtoExtraObj: V2rayNProtocolExtraShareItem?,
val TransportExtraObj: V2rayNTransportExtraShareItem?,
) {
data class V2rayNProtocolExtraShareItem(
val AlterId: Int?,
val VmessSecurity: String?,
val Flow: String?,
val VlessEncryption: String?,
val SsMethod: String?,
val WgPublicKey: String?,
val WgPresharedKey: String?,
val WgInterfaceAddress: String?,
val WgReserved: String?,
val WgMtu: Int?,
val SalamanderPass: String?,
val UpMbps: Int?,
val DownMbps: Int?,
val Ports: String?,
val HopInterval: String?,
val GroupType: String?,
val ChildItems: String?,
val SubChildItems: String?,
val Filter: String?,
val MultipleLoad: Int?,
)
data class V2rayNTransportExtraShareItem(
val RawHeaderType: String?,
val Host: String?,
val Path: String?,
val XhttpMode: String?,
val XhttpExtra: String?,
val GrpcAuthority: String?,
val GrpcServiceName: String?,
val GrpcMode: String?,
val KcpHeaderType: String?,
val KcpSeed: String?,
val KcpMtu: Int?,
)
fun toProfileItem(): ProfileItem {
val configType = when (ConfigType) {
1 -> EConfigType.VMESS
2 -> EConfigType.CUSTOM
3 -> EConfigType.SHADOWSOCKS
4 -> EConfigType.SOCKS
5 -> EConfigType.VLESS
6 -> EConfigType.TROJAN
7 -> EConfigType.HYSTERIA2
// 8 -> EConfigType.TUIC
9 -> EConfigType.WIREGUARD
10 -> EConfigType.HTTP
101 -> EConfigType.POLICYGROUP
102 -> EConfigType.PROXYCHAIN
else -> error("Unknown ConfigType: $ConfigType")
}
val network = if (configType == EConfigType.HYSTERIA2) "hysteria" else
when (Network) {
"raw" -> "tcp"
else -> NetworkType.fromString(Network).type
}
val profile = ProfileItem(
configType = configType,
remarks = Remarks.orEmpty(),
server = Address.orEmpty(),
serverPort = Port.toString(),
password = Password.orEmpty(),
method = when (configType) {
EConfigType.VMESS -> ProtoExtraObj?.VmessSecurity.orEmpty()
EConfigType.SHADOWSOCKS -> ProtoExtraObj?.SsMethod.orEmpty()
EConfigType.VLESS -> ProtoExtraObj?.VlessEncryption.orEmpty()
else -> null
},
flow = ProtoExtraObj?.Flow.orEmpty(),
username = Username.orEmpty(),
network = network,
headerType = when (network) {
NetworkType.TCP.type -> TransportExtraObj?.RawHeaderType
NetworkType.KCP.type -> TransportExtraObj?.KcpHeaderType
else -> null
},
host = TransportExtraObj?.Host,
path = TransportExtraObj?.Path,
seed = TransportExtraObj?.KcpSeed,
kcpMtu = TransportExtraObj?.KcpMtu,
mode = TransportExtraObj?.GrpcMode,
serviceName = TransportExtraObj?.GrpcServiceName,
authority = TransportExtraObj?.GrpcAuthority,
xhttpMode = TransportExtraObj?.XhttpMode,
xhttpExtra = TransportExtraObj?.XhttpExtra,
finalMask = Finalmask,
security = StreamSecurity,
sni = Sni,
alpn = Alpn,
fingerPrint = Fingerprint,
insecure = AllowInsecure?.toBoolean(),
echConfigList = EchConfigList,
verifyPeerCertByName = VerifyPeerCertByName,
pinnedCA256 = CertSha,
publicKey = PublicKey,
shortId = ShortId,
spiderX = SpiderX,
mldsa65Verify = Mldsa65Verify,
secretKey = if (configType == EConfigType.WIREGUARD) Password else null,
preSharedKey = ProtoExtraObj?.WgPresharedKey,
localAddress = ProtoExtraObj?.WgInterfaceAddress,
reserved = ProtoExtraObj?.WgReserved,
mtu = ProtoExtraObj?.WgMtu,
obfsPassword = ProtoExtraObj?.SalamanderPass,
portHopping = ProtoExtraObj?.Ports,
portHoppingInterval = ProtoExtraObj?.HopInterval,
bandwidthDown = ProtoExtraObj?.DownMbps?.takeIf { it > 0 }?.let { "${it}Mbps" },
bandwidthUp = ProtoExtraObj?.UpMbps?.takeIf { it > 0 }?.let { "${it}Mbps" },
policyGroupType = when (ProtoExtraObj?.MultipleLoad) {
2 -> BalancerStrategyType.RANDOM.policyGroupType
3 -> BalancerStrategyType.ROUND_ROBIN.policyGroupType
4 -> BalancerStrategyType.LEAST_LOAD.policyGroupType
else -> BalancerStrategyType.LEAST_PING.policyGroupType
},
// NOTE: not safe, suggest converting and rewriting
// policyGroupSubscriptionId = ProtoExtraObj?.SubChildItems,
policyGroupSubscriptionId = if (ProtoExtraObj?.SubChildItems == "self") "self" else null,
policyGroupFilter = ProtoExtraObj?.Filter,
// NOTE: proxyChainProfiles stores remarks, not IndexId
// proxyChainProfiles = ProtoExtraObj?.ChildItems,
)
// profile.description =
return profile
}
}
@@ -16,5 +16,7 @@ data class VmessQRCode(
var sni: String = "",
var alpn: String = "",
var fp: String = "",
var insecure: String = ""
var insecure: String = "",
var vcn: String = "",
var pcs: String = ""
)
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class AssetUrlCache(
val guid: String,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class AssetUrlItem(
var remarks: String = "",
@@ -0,0 +1,113 @@
package com.v2ray.ang.dto.entities
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils
data class ProfileItem(
val configVersion: Int = 4,
val configType: EConfigType,
var subscriptionId: String = "",
var addedTime: Long = System.currentTimeMillis(),
var remarks: String = "",
var description: String? = null,
var server: String? = null,
var serverPort: String? = null,
var password: String? = null,
var method: String? = null,
var flow: String? = null,
var username: String? = null,
var network: String? = null,
var headerType: String? = null,
var host: String? = null,
var path: String? = null,
var seed: String? = null,
var kcpMtu: Int? = null,
var kcpTti: Int? = null,
var quicSecurity: String? = null,
var quicKey: String? = null,
var mode: String? = null,
var serviceName: String? = null,
var authority: String? = null,
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null,
var sni: String? = null,
var alpn: String? = null,
var fingerPrint: String? = null,
var insecure: Boolean? = null,
var echConfigList: String? = null,
var verifyPeerCertByName: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null,
var shortId: String? = null,
var spiderX: String? = null,
var mldsa65Verify: String? = null,
var secretKey: String? = null,
var preSharedKey: String? = null,
var localAddress: String? = null,
var reserved: String? = null,
var mtu: Int? = null,
var remoteDNS: String? = null,
var obfsPassword: String? = null,
var portHopping: String? = null,
var portHoppingInterval: String? = null,
@Deprecated("Use pinnedCA256")
var pinSHA256: String? = null,
var bandwidthDown: String? = null,
var bandwidthUp: String? = null,
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
var policyGroupTestOutbounds: Boolean? = null,
var policyGroupFallbackTag: String? = null,
var proxyChainProfiles: String? = null,
var browserDialerMode: String? = null,
) {
companion object {
fun create(configType: EConfigType): ProfileItem =
ProfileItem(configType = configType)
}
fun getServerAddressAndPort(): String {
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
}
return "${Utils.getIpv6Address(server)}:$serverPort"
}
/**
* Dedicated identity for "remove duplicate configurations".
*
* Ignores metadata that does not affect connection:
* - configVersion
* - subscriptionId
* - addedTime
* - remarks
* - description
*
* All other fields, including configType, are included in the comparison.
*
* Returns a copy; the caller must not modify it further.
*/
fun duplicateIdentity(): ProfileItem =
copy(
configVersion = 0,
subscriptionId = "",
addedTime = 0L,
remarks = "",
description = null
)
}
@@ -1,6 +1,7 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class RulesetItem(
var id: String = "",
var remarks: String? = "",
var ip: List<String>? = null,
var domain: List<String>? = null,
@@ -0,0 +1,3 @@
package com.v2ray.ang.dto.entities
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L)
@@ -0,0 +1,7 @@
package com.v2ray.ang.dto.entities
data class ServersCache(
val guid: String,
val profile: ProfileItem,
val testDelayMillis: Long = 0L,
)
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class SubscriptionCache(
val guid: String,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class SubscriptionItem(
var remarks: String = "",
@@ -13,5 +13,7 @@ data class SubscriptionItem(
var filter: String? = null,
var allowInsecureUrl: Boolean = false,
var userAgent: String? = null,
var requestHeaders: String? = null,
var usageInfo: SubscriptionUsageInfo? = null,
)
@@ -0,0 +1,28 @@
package com.v2ray.ang.dto.entities
/**
* Account information a subscription panel (3x-ui, Marzban, Remnawave, ...) publishes in the
* HTTP response headers of the subscription URL. Every field is optional because panels send
* only the headers they have a value for.
*
* Persisted as JSON inside [SubscriptionItem]; only add fields, never rename or remove one.
*/
data class SubscriptionUsageInfo(
/** Bytes uploaded, from the `Subscription-Userinfo` header. */
val upload: Long = 0,
/** Bytes downloaded, from the `Subscription-Userinfo` header. */
val download: Long = 0,
/** Traffic quota in bytes; 0 means unlimited or not provided. */
val total: Long = 0,
/** Expiry as Unix epoch seconds; 0 means no expiry or not provided. */
val expire: Long = 0,
/** Decoded `Profile-Title` header. */
val title: String? = null,
/** Validated `Support-Url` header (usually a Telegram link). */
val supportUrl: String? = null,
/** Validated `Profile-Web-Page-Url` header. */
val webPageUrl: String? = null,
) {
/** Bytes consumed so far (upload + download). */
val used: Long get() = upload + download
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class WebDavConfig(
val baseUrl: String,
@@ -6,4 +6,4 @@ data class WebDavConfig(
val password: String? = null,
val remoteBasePath: String = "/",
val timeoutSeconds: Long = 30
)
)
@@ -0,0 +1,28 @@
package com.v2ray.ang.enums
/**
* Load-balancing strategy types supported by xray balancer.
*
* @param policyGroupType The string value written into the xray config JSON.
* @param policyGroupTypeValue The numeric string stored in [com.v2ray.ang.dto.entities.ProfileItem.policyGroupType].
* @param requiresBurstObservatory Whether this strategy needs a burstObservatory (leastLoad).
* @param requiresObservatory Whether this strategy needs an observatory (leastPing).
* @param supportsObservatory Whether an observatory can health-filter this strategy.
*/
enum class BalancerStrategyType(
val policyGroupType: String,
val policyGroupTypeValue: String,
val requiresBurstObservatory: Boolean = false,
val requiresObservatory: Boolean = false,
val supportsObservatory: Boolean = false,
) {
LEAST_LOAD("leastLoad", "1", requiresBurstObservatory = true),
RANDOM("random", "2", supportsObservatory = true),
ROUND_ROBIN("roundRobin", "3", supportsObservatory = true),
LEAST_PING("leastPing", "", requiresObservatory = true); // default / else
companion object {
fun from(policyGroupType: String?): BalancerStrategyType =
entries.firstOrNull { it.policyGroupTypeValue == policyGroupType } ?: LEAST_PING
}
}
@@ -0,0 +1,10 @@
package com.v2ray.ang.enums
enum class BrowserDialerMode(val value: String) {
OKHTTP("OkHttp"),
WEBVIEW("WebView");
companion object {
fun from(value: String?): BrowserDialerMode? = entries.find { it.value == value }
}
}
@@ -5,5 +5,4 @@ enum class CoreResolvedType {
NORMAL,
POLICYGROUP,
PROXYCHAIN,
CUSTOM,
}
@@ -1,20 +1,59 @@
package com.v2ray.ang.enums
enum class Language(val code: String) {
AUTO("auto"),
ENGLISH("en"),
CHINA("zh-rCN"),
TRADITIONAL_CHINESE("zh-rTW"),
VIETNAMESE("vi"),
RUSSIAN("ru"),
PERSIAN("fa"),
ARABIC("ar"),
BANGLA("bn"),
BAKHTIARI("bqi-rIR");
import androidx.core.os.LocaleListCompat
import java.util.Locale
enum class Language(val code: String, val languageTag: String) {
AUTO("auto", ""),
ENGLISH("en", "en"),
CHINA("zh-rCN", "zh-CN"),
TRADITIONAL_CHINESE("zh-rTW", "zh-TW"),
VIETNAMESE("vi", "vi"),
RUSSIAN("ru", "ru"),
PERSIAN("fa", "fa"),
ARABIC("ar", "ar"),
AZERBAIJANI("az", "az"),
BANGLA("bn", "bn"),
BAKHTIARI("bqi-rIR", "bqi-IR");
val locale: Locale?
get() = languageTag.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag)
fun toLocaleList(): LocaleListCompat =
if (languageTag.isEmpty()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(languageTag)
}
companion object {
fun fromCode(code: String): Language {
return entries.find { it.code == code } ?: AUTO
}
fun fromLanguageTag(languageTag: String?): Language {
if (languageTag.isNullOrBlank()) return AUTO
val locale = Locale.forLanguageTag(languageTag)
return when (locale.language) {
"en" -> ENGLISH
"zh" -> if (
locale.script == "Hant" || locale.country in setOf("TW", "HK", "MO")
) {
TRADITIONAL_CHINESE
} else {
CHINA
}
"vi" -> VIETNAMESE
"ru" -> RUSSIAN
"fa" -> PERSIAN
"ar" -> ARABIC
"az" -> AZERBAIJANI
"bn" -> BANGLA
"bqi" -> BAKHTIARI
else -> AUTO
}
}
}
}
@@ -1,22 +1,25 @@
package com.v2ray.ang.enums
import androidx.annotation.StringRes
import com.v2ray.ang.R
/**
* Enum defining different notification channels.
* Each channel has a unique channelId, notificationId, and display name.
*/
enum class NotificationChannelType(
val channelId: String,
val channelName: String,
@StringRes val channelNameRes: Int,
val notificationId: Int
) {
SUBSCRIPTION_UPDATE(
channelId = "subscription_update_channel",
channelName = "Subscription Update Service",
channelNameRes = R.string.notification_channel_subscription_updates,
notificationId = 13
),
CORE_TEST(
channelId = "core_test_channel",
channelName = "Core Test Service",
channelNameRes = R.string.notification_channel_connection_checks,
notificationId = 12
)
}
}
@@ -3,6 +3,8 @@ package com.v2ray.ang.enums
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import com.v2ray.ang.R
/**
* Permission types used in the app, handling API level differences.
@@ -13,32 +15,28 @@ enum class PermissionType {
override fun getPermission(): String = Manifest.permission.CAMERA
},
/** Read storage / media permission (adapts to Android version) */
READ_STORAGE {
override fun getPermission(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
},
/** Notification permission (Android 13+) */
POST_NOTIFICATIONS {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
},
/** Local network access permission (Android 17+) */
ACCESS_LOCAL_NETWORK {
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
override fun getPermission(): String = Manifest.permission.ACCESS_LOCAL_NETWORK
};
/** Return the actual Android permission string */
abstract fun getPermission(): String
/** Return a human-readable label for the permission */
fun getLabel(): String {
/** Return the string resource for the human-readable permission label. */
@StringRes
fun getLabelRes(): Int {
return when (this) {
CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification"
CAMERA -> R.string.permission_camera
POST_NOTIFICATIONS -> R.string.permission_notification
ACCESS_LOCAL_NETWORK -> R.string.permission_local_network
}
}
}
@@ -5,18 +5,5 @@ enum class RoutingType(val fileName: String) {
BLACK("custom_routing_black"),
GLOBAL("custom_routing_global"),
WHITE_IRAN("custom_routing_white_iran"),
WHITE_RUSSIA("custom_routing_white_russia");
companion object {
fun fromIndex(index: Int): RoutingType {
return when (index) {
0 -> WHITE
1 -> BLACK
2 -> GLOBAL
3 -> WHITE_IRAN
4 -> WHITE_RUSSIA
else -> WHITE
}
}
}
WHITE_RUSSIA("custom_routing_white_russia")
}
@@ -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,117 @@
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
private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
mainHandler.post(block)
}
}
private fun Context.dispatchMessage(
message: CharSequence,
type: ToastType,
long: Boolean = false
) {
val handledBySnackbar = AppSnackbarManager.show(
message = message,
type = type,
long = long
)
if (!handledBySnackbar) {
val appContext = applicationContext
val duration = if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
runOnMain {
Toast.makeText(appContext, message, duration).show()
}
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toast(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.NORMAL, long)
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toast(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.NORMAL, long)
}
/**
* Shows a success toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastSuccess(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.SUCCESS, long)
}
/**
* Shows a success toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastSuccess(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.SUCCESS, long)
}
/**
* Shows an error toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastError(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.ERROR, long)
}
/**
* Shows an error toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastError(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.ERROR, long)
}
/**
* Shows an info toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastInfo(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.INFO, long)
}
/**
* Shows an info toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastInfo(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.INFO, long)
}
@@ -1,74 +1,13 @@
package com.v2ray.ang.extension
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import com.v2ray.ang.AngApplication
import es.dmoral.toasty.Toasty
import com.v2ray.ang.enums.EConfigType
import java.io.Serializable
import java.net.URI
import java.util.Locale
val Context.v2RayApplication: AngApplication?
get() = applicationContext as? AngApplication
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toast(message: Int) {
Toasty.normal(this, message).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toast(message: CharSequence) {
Toasty.normal(this, message).show()
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastSuccess(message: Int) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastSuccess(message: CharSequence) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastError(message: Int) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
}
import kotlin.time.Duration.Companion.milliseconds
const val THRESHOLD = 1000L
const val DIVISOR = 1024.0
@@ -99,56 +38,6 @@ fun Long.toTrafficString(): String {
val URI.idnHost: String
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
/**
* Removes all whitespace from the string.
*
* @return The string without whitespace.
*/
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
/**
* Returns null if the string is null or blank, otherwise returns the string itself.
*
* @return The string or null.
*/
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
/**
* Converts the string to a Long value, or returns 0 if the conversion fails.
*
* @return The Long value.
*/
fun String.toLongEx(): Long = toLongOrNull() ?: 0
/**
* Listens for package changes and executes a callback when a change occurs.
*
* @param onetime Whether to unregister the receiver after the first callback.
* @param callback The callback to execute when a package change occurs.
* @return The BroadcastReceiver that was registered.
*/
fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
callback()
if (onetime) context.unregisterReceiver(this)
}
}.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
}, Context.RECEIVER_EXPORTED)
} else {
registerReceiver(this, IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REMOVED)
addDataScheme("package")
})
}
}
/**
* Retrieves a serializable object from the Bundle.
*
@@ -172,32 +61,35 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
}
/**
* Checks if the CharSequence is not null and not empty.
* Checks if the config type is a group type (PolicyGroup or ProxyChain).
*
* @return True if the CharSequence is not null and not empty, false otherwise.
* @return True if the config type is PolicyGroup or ProxyChain, false otherwise.
*/
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
fun String.concatUrl(vararg paths: String): String {
val builder = StringBuilder(this.trimEnd('/'))
paths.forEach { path ->
val trimmedPath = path.trim('/')
if (trimmedPath.isNotEmpty()) {
builder.append('/').append(trimmedPath)
}
}
return builder.toString()
fun EConfigType.isGroupType(): Boolean {
return this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
/**
* Helper function to match text either by Regex or literal string.
* Checks if the config type is a complex type (Custom, PolicyGroup, or ProxyChain).
*
* @return True if the config type is Custom, PolicyGroup, or ProxyChain, false otherwise.
*/
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
if (keyword.isNullOrEmpty()) {
return true
}
return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase)
}
fun EConfigType.isComplexType(): Boolean {
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
/**
* Shorthand for delay with Int milliseconds using Duration to avoid legacy Long overload warning.
*/
suspend fun delay(millis: Int) {
kotlinx.coroutines.delay(millis.toLong().milliseconds)
}
/**
* Shorthand for delay with Long milliseconds using Duration to avoid legacy Long overload warning.
*/
suspend fun delay(millis: Long) {
kotlinx.coroutines.delay(millis.milliseconds)
}
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.JsonUtil
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.util.HttpUtil
@@ -50,9 +50,8 @@ open class FmtBase {
*
* @param config the ProfileItem object to populate
* @param queryParam the query parameters to use for populating the ProfileItem
* @param allowInsecure whether to allow insecure connections
*/
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>, allowInsecure: Boolean) {
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>) {
config.network = queryParam["type"] ?: NetworkType.TCP.type
config.headerType = queryParam["headerType"]
config.host = queryParam["host"]
@@ -79,12 +78,13 @@ open class FmtBase {
config.insecure = when {
allowInsecureKeys.any { queryParam[it] == "1" } -> true
allowInsecureKeys.any { queryParam[it] == "0" } -> false
else -> allowInsecure
else -> false
}
config.sni = queryParam["sni"]
config.fingerPrint = queryParam["fp"]
config.alpn = queryParam["alpn"]
config.echConfigList = queryParam["ech"]
config.verifyPeerCertByName = queryParam["vcn"]
config.pinnedCA256 = queryParam["pcs"]
config.publicKey = queryParam["pbk"]
config.shortId = queryParam["sid"]
@@ -105,6 +105,7 @@ open class FmtBase {
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
config.verifyPeerCertByName?.nullIfBlank()?.let { dicQuery["vcn"] = it }
config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
@@ -1,13 +1,12 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -19,13 +18,12 @@ object Hysteria2Fmt : FmtBase() {
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.serverPort = (if (uri.port == -1) AppConfig.DEFAULT_PORT else uri.port).toString()
config.password = uri.userInfo
config.security = AppConfig.TLS
config.network = NetworkType.HYSTERIA.type
@@ -33,14 +31,11 @@ object Hysteria2Fmt : FmtBase() {
if (!uri.rawQuery.isNullOrEmpty()) {
val queryParam = getQueryParam(uri)
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
config.security = queryParam["security"] ?: AppConfig.TLS
config.obfsPassword = queryParam["obfs-password"]
config.portHopping = queryParam["mport"]
if (config.portHopping.isNotNullEmpty()) {
config.portHoppingInterval = queryParam["mportHopInt"]
}
config.pinnedCA256 = queryParam["pinSHA256"]
}
@@ -69,39 +64,6 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHopping.isNotNullEmpty()) {
dicQuery["mport"] = config.portHopping.orEmpty()
}
if (config.portHoppingInterval.isNotNullEmpty()) {
val rawInterval = config.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
null
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
null
} else {
rawInterval
}
} else {
val parts = rawInterval.split('-')
if (parts.size == 2) {
val start = parts[0].trim().toIntOrNull()
val end = parts[1].trim().toIntOrNull()
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
(minStart + minEnd) / 2
} else {
null
}
} else {
null
}
}
}
if (interval != null) {
dicQuery["mportHopInt"] = interval.toString()
}
}
if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
}
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
@@ -1,6 +1,6 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
@@ -26,7 +26,11 @@ object SocksFmt : FmtBase() {
config.serverPort = uri.port.toString()
if (uri.userInfo?.isEmpty() == false) {
val result = Utils.decode(uri.userInfo).split(":", limit = 2)
val result = if (uri.userInfo.contains(":")) {
uri.userInfo.split(":", limit = 2)
} else {
Utils.decode(uri.userInfo).split(":", limit = 2)
}
if (result.count() == 2) {
config.username = result.first()
config.password = result.last()
@@ -1,11 +1,10 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -17,7 +16,6 @@ object TrojanFmt : FmtBase() {
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -29,11 +27,11 @@ object TrojanFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) {
config.network = NetworkType.TCP.type
config.security = AppConfig.TLS
config.insecure = allowInsecure
config.insecure = false
} else {
val queryParam = getQueryParam(uri)
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
config.security = queryParam["security"] ?: AppConfig.TLS
}
@@ -0,0 +1,48 @@
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.extension.isNotNullEmpty
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
object V2rayNFmt : FmtBase() {
fun parse(list: List<String>, subId: String): List<ProfileItem> {
val idMap = mutableMapOf<String, V2rayNShareItem>()
list.mapNotNull(::parseShareItem).forEach { item ->
idMap.putIfAbsent(item.IndexId.orEmpty(), item)
}
return idMap.values.map { item ->
item.toProfileItem().apply {
val proto = item.ProtoExtraObj
policyGroupSubscriptionId = if (proto?.SubChildItems == "self") {
subId
} else {
null
}
proto?.ChildItems?.takeIf { it.isNotNullEmpty() }?.let { ids ->
val remarks = ids.split(",")
.mapNotNull { idMap[it]?.Remarks }
.filter { it.isNotNullEmpty() }
if (remarks.isNotEmpty()) {
when (item.ConfigType) {
101 -> policyGroupFilter = remarks.joinToString("|", "^(", ")$") { Regex.escape(it) }
102 -> proxyChainProfiles = remarks.joinToString(",")
}
}
}
}
}
}
private fun parseShareItem(str: String): V2rayNShareItem? = try {
JsonUtil.fromJson(Utils.decode(str.substringAfterLast('/')), V2rayNShareItem::class.java)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN share item", e)
null
}
}
@@ -1,10 +1,8 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -17,7 +15,6 @@ object VlessFmt : FmtBase() {
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VLESS)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -30,7 +27,7 @@ object VlessFmt : FmtBase() {
config.password = uri.userInfo
config.method = queryParam["encryption"] ?: "none"
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
return config
}
@@ -2,13 +2,12 @@ package com.v2ray.ang.fmt
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
@@ -26,7 +25,6 @@ object VmessFmt : FmtBase() {
return parseVmessStd(str)
}
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VMESS)
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
@@ -87,8 +85,11 @@ object VmessFmt : FmtBase() {
config.insecure = when (vmessQRCode.insecure) {
"1" -> true
"0" -> false
else -> allowInsecure
else -> false
}
config.verifyPeerCertByName = vmessQRCode.vcn
config.pinnedCA256 = vmessQRCode.pcs
return config
}
@@ -142,6 +143,8 @@ object VmessFmt : FmtBase() {
false -> "0"
else -> ""
}
vmessQRCode.vcn = config.verifyPeerCertByName.orEmpty()
vmessQRCode.pcs = config.pinnedCA256.orEmpty()
val json = JsonUtil.toJson(vmessQRCode)
return Utils.encode(json)
@@ -154,7 +157,6 @@ object VmessFmt : FmtBase() {
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parseVmessStd(str: String): ProfileItem? {
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VMESS)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -167,7 +169,7 @@ object VmessFmt : FmtBase() {
config.password = uri.userInfo
config.method = AppConfig.DEFAULT_SECURITY
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
return config
}
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
@@ -32,7 +32,9 @@ object WireguardFmt : FmtBase() {
config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.remoteDNS = queryParam["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
config.reserved = queryParam["reserved"] ?: "0,0,0"
config.finalMask = (queryParam["fm"] ?: queryParam["finalmask"] ?: queryParam["finalMask"])?.nullIfBlank()
return config
}
@@ -81,6 +83,7 @@ object WireguardFmt : FmtBase() {
config.remarks = System.currentTimeMillis().toString()
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.remoteDNS = interfaceParams["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
val endpoint = peerParams["endpoint"].orEmpty()
@@ -93,6 +96,7 @@ object WireguardFmt : FmtBase() {
config.serverPort = ""
}
config.reserved = peerParams["reserved"] ?: "0,0,0"
config.finalMask = (peerParams["finalmask"] ?: peerParams["fm"] ?: interfaceParams["finalmask"] ?: interfaceParams["fm"])?.nullIfBlank()
return config
}
@@ -118,6 +122,10 @@ object WireguardFmt : FmtBase() {
if (config.preSharedKey != null) {
dicQuery["presharedkey"] = config.preSharedKey.removeWhiteSpace().orEmpty()
}
if (config.remoteDNS != null) {
dicQuery["dns"] = config.remoteDNS.removeWhiteSpace().orEmpty()
}
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
return toUri(config, config.secretKey, dicQuery)
}
@@ -4,13 +4,13 @@ import android.content.Context
import android.graphics.Bitmap
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt
@@ -18,6 +18,7 @@ import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt
import com.v2ray.ang.fmt.SocksFmt
import com.v2ray.ang.fmt.TrojanFmt
import com.v2ray.ang.fmt.V2rayNFmt
import com.v2ray.ang.fmt.VlessFmt
import com.v2ray.ang.fmt.VmessFmt
import com.v2ray.ang.fmt.WireguardFmt
@@ -26,10 +27,31 @@ import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import okhttp3.Headers
import java.net.URI
object AngConfigManager {
private data class ParsedProfile(
val profile: ProfileItem,
val rawConfig: String? = null,
)
// Parser mapping for different config types (lazy initialized)
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
mapOf(
EConfigType.VMESS.protocolScheme to VmessFmt::parse,
EConfigType.SHADOWSOCKS.protocolScheme to ShadowsocksFmt::parse,
EConfigType.SOCKS.protocolScheme to SocksFmt::parse,
AppConfig.SOCKS4 to SocksFmt::parse,
AppConfig.SOCKS5 to SocksFmt::parse,
EConfigType.TROJAN.protocolScheme to TrojanFmt::parse,
EConfigType.VLESS.protocolScheme to VlessFmt::parse,
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
AppConfig.HY2 to Hysteria2Fmt::parse,
)
}
/**
* Shares the configuration to the clipboard.
@@ -160,23 +182,28 @@ object AngConfigManager {
* @return A pair containing the number of configurations and subscriptions imported.
*/
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
var count = parseBatchConfig(Utils.decode(server), subid, append)
if (count <= 0) {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid, append)
}
return try {
var count = parseBatchConfig(Utils.decode(server), subid, append)
if (count <= 0) {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid, append)
}
var countSub = parseBatchSubscription(server)
if (countSub <= 0) {
countSub = parseBatchSubscription(Utils.decode(server))
}
if (countSub > 0) {
updateConfigViaSubAll()
}
var countSub = parseBatchSubscription(server)
if (countSub <= 0) {
countSub = parseBatchSubscription(Utils.decode(server))
}
if (countSub > 0) {
updateConfigViaSubAll()
}
return count to countSub
count to countSub
} catch (e: ProfileStorageException) {
LogUtil.e(AppConfig.TAG, "Failed to store imported profiles", e)
0 to 0
}
}
/**
@@ -219,41 +246,40 @@ object AngConfigManager {
if (servers == null) {
return 0
}
// Find the currently selected server that matches the subscription ID
val removedSelected = if (subid.isNotBlank() && !append) {
MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
} else {
null
}
val subItem = MmkvManager.decodeSubscription(subid)
// Parse all configs first (no I/O during parsing)
val configs = mutableListOf<ProfileItem>()
val v2raynLines = mutableListOf<String>()
servers.lines()
.distinct()
.reversed()
.forEach {
val config = parseConfig(it, subid, subItem)
if (config != null) {
configs.add(config)
if (it.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)) {
v2raynLines.add(it)
} else {
val config = parseConfig(it, subid, subItem)
if (config != null) {
configs.add(config)
}
}
}
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
val v2raynConfigs = V2rayNFmt.parse(v2raynLines, subid)
val allConfigs = v2raynConfigs + configs
if (allConfigs.isNotEmpty()) {
commitProfiles(
configs = allConfigs.map(::ParsedProfile),
subid = subid,
append = append,
)
}
return configs.size
return allConfigs.size
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
}
@@ -261,93 +287,32 @@ object AngConfigManager {
}
/**
* Batch save configurations to reduce serverList read/write operations.
* Reads serverList once, saves all configs, then writes serverList once.
* Commits parsed profiles before removing the profiles they replace.
*
* @param configs The list of ProfileItem to save.
* @param configs The parsed profiles to save.
* @param subid The subscription ID.
* @return Map of generated keys to their corresponding ProfileItem.
* @param append Whether to append to the existing server list.
*/
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
val keyToProfile = mutableMapOf<String, ProfileItem>()
private fun commitProfiles(
configs: List<ParsedProfile>,
subid: String,
append: Boolean,
) {
val keyToProfile = linkedMapOf<String, ProfileItem>()
val rawConfigs = mutableMapOf<String, String>()
// Read serverList once
val serverList = MmkvManager.decodeServerList(subid)
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
configs.forEach { config ->
configs.forEach { parsed ->
val key = Utils.getUuid()
// Save profile directly without updating serverList
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
if (!serverList.contains(key)) {
serverList.add(0, key)
if (needSetSelected) {
MmkvManager.setSelectServer(key)
needSetSelected = false
}
}
keyToProfile[key] = config
keyToProfile[key] = parsed.profile
parsed.rawConfig?.let { raw -> rawConfigs[key] = raw }
}
// Write serverList once
MmkvManager.encodeServerList(serverList, subid)
return keyToProfile
}
/**
* Finds a matched profile key from the given key-profile map using multi-level matching.
* Matching priority (from highest to lowest):
* 1. Exact match: server + port + password
* 2. Match by remarks (exact match)
* 3. Match by server + port
* 4. Match by server only
*
* @param keyToProfile Map of server keys to their ProfileItem
* @param target Target profile to match
* @return Matched key or null
*/
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty() || target == null) return null
// Level 1: Match by remarks
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks)
}?.key?.let { return it }
}
// Level 2: Exact match (server + port + password)
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
// Level 3: Match by server + port
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort)
}?.key?.let { return it }
// Level 4: Match by server only
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server)
}?.key?.let { return it }
return null
}
/**
* Case-insensitive trimmed string comparison.
*
* @param left First string
* @param right Second string
* @return True if both are non-empty and equal (case-insensitive, trimmed)
*/
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
MmkvManager.saveServerProfiles(
profiles = keyToProfile,
rawConfigs = rawConfigs,
subscriptionId = subid,
append = append,
)
}
/**
@@ -371,49 +336,54 @@ object AngConfigManager {
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
val configs = serverList.reversed().map { srv ->
val config = CustomFmt.parse(JsonUtil.toJson(srv))
config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1
ParsedProfile(
profile = config,
rawConfig = JsonUtil.toJsonPretty(srv) ?: "",
)
}
return count
commitProfiles(configs, subid, append)
return configs.size
}
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
}
try {
// For compatibility
val config = CustomFmt.parse(server) ?: return 0
val config = CustomFmt.parse(server)
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
commitProfiles(
configs = listOf(ParsedProfile(config, server)),
subid = subid,
append = append,
)
return 1
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
val config = WireguardFmt.parseWireguardConfFile(server)
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
commitProfiles(
configs = listOf(ParsedProfile(config, server)),
subid = subid,
append = append,
)
return 1
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
@@ -442,22 +412,8 @@ object AngConfigManager {
return null
}
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
VmessFmt.parse(str)
} else if (str.startsWith(EConfigType.SHADOWSOCKS.protocolScheme)) {
ShadowsocksFmt.parse(str)
} else if (str.startsWith(EConfigType.SOCKS.protocolScheme)) {
SocksFmt.parse(str)
} else if (str.startsWith(EConfigType.TROJAN.protocolScheme)) {
TrojanFmt.parse(str)
} else if (str.startsWith(EConfigType.VLESS.protocolScheme)) {
VlessFmt.parse(str)
} else if (str.startsWith(EConfigType.WIREGUARD.protocolScheme)) {
WireguardFmt.parse(str)
} else if (str.startsWith(EConfigType.HYSTERIA2.protocolScheme) || str.startsWith(HY2)) {
Hysteria2Fmt.parse(str)
} else {
null
val config = configFmtParsers.firstNotNullOfOrNull { (scheme, parser) ->
if (str.startsWith(scheme)) parser(str) else null
}
if (config == null) {
@@ -530,19 +486,49 @@ 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()
// Account info (traffic, expiry, support link) the panel sends in the response headers.
var usageInfo: SubscriptionUsageInfo? = null
val captureUsageInfo: (Headers) -> Unit = { headers ->
usageInfo = SubscriptionInfoParser.parse(
userInfo = headers["Subscription-Userinfo"],
profileTitle = headers["Profile-Title"],
supportUrl = headers["Support-Url"],
webPageUrl = headers["Profile-Web-Page-Url"],
)
}
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort, proxyUsername, proxyPassword)
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent,
requestHeaders = requestHeaders,
timeout = 15000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
),
captureUsageInfo
)
} catch (e: Exception) {
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent,
requestHeaders = requestHeaders
),
captureUsageInfo
)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
@@ -555,6 +541,8 @@ object AngConfigManager {
val count = parseConfigViaSub(configText, it.guid, false)
if (count > 0) {
it.subscription.lastUpdated = System.currentTimeMillis()
// Replaced on every successful update so the box disappears if the panel stops sending the headers.
it.subscription.usageInfo = usageInfo
MmkvManager.encodeSubscription(it.guid, it.subscription)
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
return SubscriptionUpdateResult(
@@ -571,6 +559,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,140 @@
package com.v2ray.ang.handler
import android.app.LocaleManager
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.LocaleManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.os.ConfigurationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.Language
/**
* Keeps the legacy in-app language preference synchronized with Android's per-app locale APIs.
*/
object AppLocaleManager {
/**
* Migrates the existing MMKV language preference and restores it before the first activity.
*/
fun initialize(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Api33.prepareMigration(context)
} else {
initializeCompat(context)
}
}
/**
* Completes the one-time handoff after AppCompat has attached the activity context.
*/
fun onActivityCreated(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Api33.completeMigration(context)
} else {
syncLegacyPreference(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag())
}
}
/**
* Applies a language selected in the app and lets AppCompat recreate the current activity.
*/
fun setApplicationLanguage(languageCode: String) {
val language = Language.fromCode(languageCode)
persistLegacyPreference(language)
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
}
/**
* Returns a context suitable for resource access outside AppCompatActivity.
*/
fun localizedContext(context: Context): Context {
val localizedContext = ContextCompat.getContextForLanguage(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || localizedContext !== context) {
return localizedContext
}
// During the one-time upgrade from the old picker, AppCompat has not written its locale
// record yet. Use the still-current MMKV value for non-activity contexts on that launch.
val language = storedLanguage()
language.locale ?: return context
val configuration = Configuration(context.resources.configuration)
ConfigurationCompat.setLocales(configuration, language.toLocaleList())
return context.createConfigurationContext(configuration)
}
private fun initializeCompat(context: Context) {
val storedLocales = LocaleManagerCompat.getApplicationLocales(context)
if (storedLocales.isEmpty) {
val legacyLanguage = storedLanguage()
if (legacyLanguage != Language.AUTO) {
AppCompatDelegate.setApplicationLocales(legacyLanguage.toLocaleList())
}
} else {
syncLegacyPreference(storedLocales.get(0)?.toLanguageTag())
}
}
private fun storedLanguage(): Language = Language.fromCode(
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
)
private fun syncLegacyPreference(languageTag: String?) {
persistLegacyPreference(Language.fromLanguageTag(languageTag))
}
private fun persistLegacyPreference(language: Language) {
val current = MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE)
if (current != language.code) {
MmkvManager.encodeSettings(AppConfig.PREF_LANGUAGE, language.code)
SettingsChangeManager.notifySettingChanged(AppConfig.PREF_LANGUAGE)
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private object Api33 {
fun prepareMigration(context: Context) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
return
}
val localeManager = context.getSystemService(LocaleManager::class.java)
val applicationLocales = localeManager.applicationLocales
if (!applicationLocales.isEmpty) {
syncLegacyPreference(applicationLocales.get(0)?.toLanguageTag())
return
}
val legacyLanguage = storedLanguage()
if (legacyLanguage != Language.AUTO) {
// Make application and service contexts correct even before the first activity.
localeManager.applicationLocales = android.os.LocaleList(
requireNotNull(legacyLanguage.locale)
)
}
}
fun completeMigration(context: Context) {
val localeManager = context.getSystemService(LocaleManager::class.java)
if (!MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
val frameworkLanguage = Language.fromLanguageTag(
localeManager.applicationLocales.get(0)?.toLanguageTag()
)
val language = frameworkLanguage.takeUnless { it == Language.AUTO }
?: storedLanguage()
// Android's migration guidance requires this one-time AppCompat handoff after
// Activity.onCreate(), including on Android 13 and newer.
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
persistLegacyPreference(language)
MmkvManager.encodeSettings(AppConfig.PREF_APP_LOCALE_MIGRATED, true)
return
}
syncLegacyPreference(localeManager.applicationLocales.get(0)?.toLanguageTag())
}
}
}
@@ -0,0 +1,79 @@
package com.v2ray.ang.handler
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.CertSha256Request
import com.v2ray.ang.dto.CertSha256Result
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import libv2ray.Libv2ray
object CertificateFingerprintManager {
private const val TIMEOUT_MS = 5000L
fun fetchForManualFill(profile: ProfileItem): String? {
val request = buildRequest(profile) ?: return null
val result = if (profile.configType == EConfigType.HYSTERIA2) {
fetch("quic", request) { Libv2ray.fetchQuicCertSha256(it) }
} else {
fetch("tls", request) { Libv2ray.fetchTlsCertSha256(it) }
}
return result
?.takeIf { it.error.isBlank() }
?.sha256
?.takeIf { it.isNotBlank() }
}
private fun buildRequest(profile: ProfileItem): CertSha256Request? {
if (!isFetchable(profile)) return null
val server = profile.server?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val port = profile.serverPort?.toIntOrNull()?.takeIf { it > 0 } ?: AppConfig.DEFAULT_PORT
return CertSha256Request(
address = resolveDialAddress(server),
port = port,
serverName = inferServerName(profile),
timeoutMs = TIMEOUT_MS,
)
}
private fun isFetchable(profile: ProfileItem): Boolean {
return profile.configType == EConfigType.HYSTERIA2 || profile.security == AppConfig.TLS
}
private fun fetch(
type: String,
request: CertSha256Request,
fetcher: (String) -> String,
): CertSha256Result? {
return try {
JsonUtil.fromJsonSafe(fetcher(JsonUtil.toJson(request)), CertSha256Result::class.java)
} catch (e: UnsatisfiedLinkError) {
LogUtil.e(AppConfig.TAG, "Fetch $type cert SHA-256 API missing in libv2ray", e)
null
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Fetch $type cert SHA-256 failed", e)
null
}
}
private fun resolveDialAddress(server: String): String {
if (Utils.isPureIpAddress(server) || !Utils.isDomainName(server)) return server
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6, false)
return HttpUtil.resolveHostToIP(server, preferIpv6)
?.firstOrNull()
?.takeIf { it.isNotBlank() }
?: server
}
private fun inferServerName(profile: ProfileItem): String? {
val sni = profile.sni?.takeIf { it.isNotBlank() }
return sni?.takeUnless { Utils.isPureIpAddress(it) }
}
}
@@ -1,19 +1,38 @@
package com.v2ray.ang.handler
import android.content.Context
import android.util.Log
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.tencent.mmkv.MMKVHandler
import com.tencent.mmkv.MMKVLogLevel
import com.tencent.mmkv.MMKVRecoverStrategic
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlCache
import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServerAffiliationInfo
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.AppConfig.TAG
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.isGroupType
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
internal class ProfileStorageException(message: String) : IllegalStateException(message)
object MmkvManager {
@@ -32,6 +51,24 @@ object MmkvManager {
private const val KEY_SUB_IDS = "SUB_IDS"
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
private val recoveryHandler = object : MMKVHandler {
override fun onMMKVCRCCheckFail(mmapID: String) =
recoverFromStorageError(mmapID, "CRC check")
override fun onMMKVFileLengthError(mmapID: String) =
recoverFromStorageError(mmapID, "file length check")
override fun wantLogRedirecting(): Boolean = false
override fun mmkvLog(
level: MMKVLogLevel,
file: String,
line: Int,
function: String,
message: String
) = Unit
}
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
@@ -40,8 +77,83 @@ object MmkvManager {
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
private inline fun <T> withProfileIndexLock(block: () -> T): T {
return synchronized(mainStorage) {
mainStorage.lock()
try {
block()
} finally {
mainStorage.unlock()
}
}
}
private fun removeProfilePayloads(guids: Collection<String>) {
if (guids.isEmpty()) return
val keys = guids.toTypedArray()
profileFullStorage.removeValuesForKeys(keys)
serverAffStorage.removeValuesForKeys(keys)
serverRawStorage.removeValuesForKeys(keys)
}
private fun requireStorageWrite(success: Boolean, message: String) {
if (!success) throw ProfileStorageException(message)
}
private fun persistServerList(serverList: List<String>, subscriptionId: String): Boolean {
return mainStorage.encode(serverListKey(subscriptionId), JsonUtil.toJson(serverList))
}
private fun serverListKey(subscriptionId: String): String {
return "$KEY_SUB_SERVER_PREFIX${getSubscriptionId(subscriptionId)}"
}
/**
* Returns every server referenced outside the target group, or null if the raw indexes
* cannot provide a complete view.
*/
private fun decodeServersReferencedByOtherGroups(subscriptionId: String): Set<String>? {
val targetKey = serverListKey(subscriptionId)
val keys = mainStorage.allKeys() ?: return null
if (targetKey !in keys) return null
val referencedServers = mutableSetOf<String>()
for (key in keys) {
if (!key.startsWith(KEY_SUB_SERVER_PREFIX) || key == targetKey) continue
val json = mainStorage.decodeString(key)
if (json.isNullOrBlank()) return null
val serverIds = JsonUtil.fromJsonSafe(json, Array<String>::class.java) ?: return null
referencedServers.addAll(serverIds)
}
return referencedServers
}
//endregion
/**
* Initializes MMKV with best-effort recovery so a damaged store is not silently discarded.
*/
fun initialize(context: Context) {
val logLevel = if (BuildConfig.DEBUG) {
MMKVLogLevel.LevelDebug
} else {
MMKVLogLevel.LevelInfo
}
MMKV.initialize(
context,
context.filesDir.resolve("mmkv").absolutePath,
null,
logLevel,
recoveryHandler
)
}
private fun recoverFromStorageError(mmapID: String, error: String): MMKVRecoverStrategic {
Log.e(TAG, "MMKV $error failed for $mmapID; attempting data recovery")
return MMKVRecoverStrategic.OnErrorRecover
}
//region Server
/**
@@ -70,7 +182,9 @@ object MmkvManager {
* @param guid The server GUID.
*/
fun setSelectServer(guid: String) {
mainStorage.encode(KEY_SELECTED_SERVER, guid)
withProfileIndexLock {
mainStorage.encode(KEY_SELECTED_SERVER, guid)
}
}
/**
@@ -81,9 +195,9 @@ object MmkvManager {
* @param subscriptionId The subscription ID.
*/
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
mainStorage.encode(key, JsonUtil.toJson(serverList))
withProfileIndexLock {
persistServerList(serverList, subscriptionId)
}
}
@@ -96,13 +210,11 @@ object MmkvManager {
* @return The list of server GUIDs.
*/
fun decodeServerList(subscriptionId: String): MutableList<String> {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
val json = mainStorage.decodeString(key)
val json = mainStorage.decodeString(serverListKey(subscriptionId))
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
@@ -144,7 +256,7 @@ object MmkvManager {
if (json.isNullOrBlank()) {
return null
}
return JsonUtil.fromJson(json, ProfileItem::class.java)
return JsonUtil.fromJsonSafe(json, ProfileItem::class.java)
}
@@ -157,17 +269,28 @@ object MmkvManager {
*/
fun encodeServerConfig(guid: String, config: ProfileItem): String {
val key = guid.ifBlank { Utils.getUuid() }
profileFullStorage.encode(key, JsonUtil.toJson(config))
withProfileIndexLock {
requireStorageWrite(
profileFullStorage.encode(key, JsonUtil.toJson(config)),
"Failed to save profile payload",
)
// Use default subscription for servers without subscription
val subId = getSubscriptionId(config.subscriptionId)
val serverList = decodeServerList(subId)
// Use default subscription for servers without subscription
val subId = getSubscriptionId(config.subscriptionId)
val serverList = decodeServerList(subId)
if (!serverList.contains(key)) {
serverList.add(0, key)
encodeServerList(serverList, subId)
if (getSelectServer().isNullOrBlank()) {
mainStorage.encode(KEY_SELECTED_SERVER, key)
if (!serverList.contains(key)) {
serverList.add(0, key)
requireStorageWrite(
persistServerList(serverList, subId),
"Failed to publish profile index",
)
if (getSelectServer().isNullOrBlank()) {
requireStorageWrite(
mainStorage.encode(KEY_SELECTED_SERVER, key),
"Failed to update selected profile",
)
}
}
}
@@ -175,13 +298,90 @@ object MmkvManager {
}
/**
* Encodes the server configuration directly without updating serverList.
* Saves a profile batch before publishing its group index and removing replaced payloads.
*
* @param key The server GUID.
* @param configJson The server configuration JSON string.
* @param profiles Generated GUIDs and parsed profiles, in insertion order.
* @param rawConfigs Optional raw configuration payloads keyed by profile GUID.
* @param subscriptionId The destination subscription ID.
* @param append Whether to append to the existing group index.
*/
fun encodeProfileDirect(key: String, configJson: String) {
profileFullStorage.encode(key, configJson)
internal fun saveServerProfiles(
profiles: Map<String, ProfileItem>,
rawConfigs: Map<String, String>,
subscriptionId: String,
append: Boolean,
) {
if (profiles.isEmpty()) return
withProfileIndexLock {
val replacedServers = if (append) {
emptyList()
} else {
decodeServerList(subscriptionId).toList()
}
val previousSelection = getSelectServer()
val selectedProfile = if (!append &&
previousSelection != null &&
previousSelection in replacedServers
) {
decodeServerConfig(previousSelection)
} else {
null
}
val replacementSelection = ProfileReplacement.findSelectedReplacement(
profiles = profiles,
currentSelection = previousSelection,
selectedProfile = selectedProfile,
)
profiles.forEach { (guid, profile) ->
requireStorageWrite(
profileFullStorage.encode(guid, JsonUtil.toJson(profile)),
"Failed to save profile payload",
)
rawConfigs[guid]?.let { raw ->
requireStorageWrite(
serverRawStorage.encode(guid, raw),
"Failed to save raw profile payload",
)
}
}
val serverList = if (append) {
decodeServerList(subscriptionId)
} else {
replacedServers.filter { guid ->
decodeServerConfig(guid)?.configType?.isGroupType() == true
}.toMutableList()
}
val indexedServers = serverList.toHashSet()
profiles.keys.forEach { guid ->
if (indexedServers.add(guid)) {
serverList.add(0, guid)
}
}
requireStorageWrite(
persistServerList(serverList, subscriptionId),
"Failed to publish profile index",
)
replacementSelection?.let { guid ->
requireStorageWrite(
mainStorage.encode(KEY_SELECTED_SERVER, guid),
"Failed to update selected profile",
)
}
if (replacedServers.isEmpty()) return@withProfileIndexLock
val protectedServer = replacementSelection ?: previousSelection
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
val removablePayloads = ProfileReplacement.findRemovablePayloads(
replacedServers = replacedServers,
replacementServers = serverList.toSet(),
protectedServer = protectedServer,
serversReferencedByOtherGroups = referencedByOtherGroups,
)
removeProfilePayloads(removablePayloads)
}
}
/**
@@ -233,6 +433,31 @@ object MmkvManager {
encodeServerList(serverList, subId)
}
/**
* Removes multiple server configurations from a subscription.
*
* @param guids The list of server GUIDs.
* @param subscriptionId The subscription ID.
*/
fun removeServers(guids: List<String>, subscriptionId: String) {
if (guids.isEmpty()) return
val subId = getSubscriptionId(subscriptionId)
val serverList = decodeServerList(subId)
if (serverList.removeAll(guids)) {
encodeServerList(serverList, subId)
}
val selectedServer = getSelectServer()
guids.forEach { guid ->
if (selectedServer == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
profileFullStorage.remove(guid)
serverAffStorage.remove(guid)
serverRawStorage.remove(guid)
}
}
/**
* Decodes the server affiliation information.
*
@@ -247,7 +472,7 @@ object MmkvManager {
if (json.isNullOrBlank()) {
return null
}
return JsonUtil.fromJson(json, ServerAffiliationInfo::class.java)
return JsonUtil.fromJsonSafe(json, ServerAffiliationInfo::class.java)
}
/**
@@ -286,9 +511,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
}
@@ -340,6 +569,56 @@ object MmkvManager {
return serverRawStorage.decodeString(guid)
}
/**
* Removes profile payloads that are provably absent from their raw SUB_SERVERS_* index.
*
* SUB_IDS and SUB are intentionally ignored: either store can be missing after MMKV
* recovery while the group indexes still identify live profiles. If any group index or
* profile payload needed for a decision is unreadable, that data is preserved.
*
* @return The number of profile payloads removed, or null if cleanup could not run safely.
*/
internal fun removeOrphanedServerProfiles(): Int? = synchronized(mainStorage) {
mainStorage.lock()
try {
val indexedServersBySubscription = mainStorage.allKeys().orEmpty()
.asSequence()
.filter { key -> key.startsWith(KEY_SUB_SERVER_PREFIX) }
.associate { key ->
val subscriptionId = key.removePrefix(KEY_SUB_SERVER_PREFIX)
val json = mainStorage.decodeString(key)
val serverIds = if (json.isNullOrBlank()) {
null
} else {
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toSet()
}
subscriptionId to serverIds
}
val profiles = profileFullStorage.allKeys().orEmpty().map { guid ->
StoredProfileReference(
guid = guid,
subscriptionId = decodeServerConfig(guid)?.subscriptionId,
)
}
val orphans = OrphanProfileCleaner.findOrphans(
profiles = profiles,
indexedServersBySubscription = indexedServersBySubscription,
selectedServer = getSelectServer(),
) ?: return@synchronized null
if (orphans.isNotEmpty()) {
val keys = orphans.toTypedArray()
profileFullStorage.removeValuesForKeys(keys)
serverAffStorage.removeValuesForKeys(keys)
serverRawStorage.removeValuesForKeys(keys)
}
orphans.size
} finally {
mainStorage.unlock()
}
}
//endregion
//region Subscriptions
@@ -374,7 +653,7 @@ object MmkvManager {
decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java) ?: SubscriptionItem()
val item = JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java) ?: SubscriptionItem()
subscriptions.add(SubscriptionCache(key, item))
}
}
@@ -420,7 +699,7 @@ object MmkvManager {
*/
fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
val json = subStorage.decodeString(subscriptionId) ?: return null
return JsonUtil.fromJson(json, SubscriptionItem::class.java)
return JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java)
}
/**
@@ -442,7 +721,8 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
// Keep the first occurrence so a damaged index cannot produce duplicate Compose keys.
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.distinct()?.toMutableList() ?: mutableListOf()
}
}
@@ -460,7 +740,7 @@ object MmkvManager {
assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java) ?: AssetUrlItem()
val item = JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java) ?: AssetUrlItem()
assetUrlItems.add(AssetUrlCache(key, item))
}
}
@@ -495,7 +775,7 @@ object MmkvManager {
*/
fun decodeAsset(assetid: String): AssetUrlItem? {
val json = assetStorage.decodeString(assetid) ?: return null
return JsonUtil.fromJson(json, AssetUrlItem::class.java)
return JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java)
}
//endregion
@@ -510,7 +790,7 @@ object MmkvManager {
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
if (ruleset.isNullOrEmpty()) return null
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
return JsonUtil.fromJsonSafe(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
}
/**
@@ -714,7 +994,59 @@ object MmkvManager {
*/
fun decodeWebDavConfig(): WebDavConfig? {
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
return JsonUtil.fromJson(json, WebDavConfig::class.java)
return JsonUtil.fromJsonSafe(json, WebDavConfig::class.java)
}
//endregion
//region Compose helpers for Settings
/**
* MMKV-backed String state, auto-persists and notifies on change.
*/
@Composable
fun rememberMmkvString(
key: String,
default: String = ""
): MutableState<String> {
val state = remember(key) {
mutableStateOf(decodeSettingsString(key, default) ?: default)
}
LaunchedEffect(key) {
snapshotFlow { state.value }
.drop(1)
.distinctUntilChanged()
.collectLatest { value ->
encodeSettings(key, value)
SettingsChangeManager.notifySettingChanged(key)
}
}
return state
}
/**
* MMKV-backed Boolean state, auto-persists and notifies on change.
*/
@Composable
fun rememberMmkvBool(
key: String,
default: Boolean = false
): MutableState<Boolean> {
val state = remember(key) {
mutableStateOf(decodeSettingsBool(key, default))
}
LaunchedEffect(key) {
snapshotFlow { state.value }
.drop(1)
.distinctUntilChanged()
.collectLatest { value ->
encodeSettings(key, value)
SettingsChangeManager.notifySettingChanged(key)
}
}
return state
}
//endregion
@@ -1,7 +1,6 @@
package com.v2ray.ang.handler
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
@@ -14,14 +13,15 @@ import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.delay
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.ui.MainActivity
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.ui.main.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.min
@@ -43,53 +43,15 @@ object NotificationManager {
* Starts the speed notification.
* @param currentConfig The current profile configuration.
*/
fun startSpeedNotification(currentConfig: ProfileItem?) {
fun startSpeedNotification() {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags()
outboundTags?.remove(AppConfig.TAG_DIRECT)
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
val queryTime = System.currentTimeMillis()
val sinceLastQueryIn = (queryTime - lastQueryTime)
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
delay(QUERY_INTERVAL_MS)
continue
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
val up = CoreServiceManager.queryStats(it, AppConfig.UPLINK)
val down = CoreServiceManager.queryStats(it, AppConfig.DOWNLINK)
if (up + down > 0) {
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
proxyTotal += up + down
}
}
val directUplink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
if (!zeroSpeed || !lastZeroSpeed) {
if (proxyTotal == 0L) {
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
}
appendSpeedString(
text, AppConfig.TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
}
lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime
lastZeroSpeed = updateSpeedNotificationOnce(lastZeroSpeed)
delay(QUERY_INTERVAL_MS)
}
}
@@ -131,8 +93,8 @@ object NotificationManager {
mBuilder = NotificationCompat.Builder(service, channelId)
.setSmallIcon(R.drawable.ic_stat_name)
.setContentTitle(currentConfig?.remarks)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setContentTitle(currentConfig?.remarks ?: service.getString(R.string.app_name))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setShowWhen(false)
.setOnlyAlertOnce(true)
@@ -143,7 +105,7 @@ object NotificationManager {
stopV2RayPendingIntent
)
.addAction(
R.drawable.ic_delete_24dp,
R.drawable.ic_restore_24dp,
service.getString(R.string.title_service_restart),
restartV2RayPendingIntent
)
@@ -153,6 +115,17 @@ object NotificationManager {
service.startForeground(NOTIFICATION_ID, mBuilder?.build())
}
/**
* Fulfills or refreshes the foreground-service contract before a start command can
* return early. A duplicate startForegroundService call still requires the service
* to enter foreground state promptly, even when the core is already running.
*/
fun ensureForeground() {
val service = getService() ?: return
val notification = mBuilder?.build()
if (notification == null) showNotification(null) else service.startForeground(NOTIFICATION_ID, notification)
}
/**
* Cancels the notification.
*/
@@ -168,13 +141,12 @@ object NotificationManager {
/**
* Stops the speed notification.
* @param currentConfig The current profile configuration.
*/
fun stopSpeedNotification(currentConfig: ProfileItem?) {
fun stopSpeedNotification() {
speedNotificationJob?.let {
it.cancel()
speedNotificationJob = null
updateNotification(currentConfig?.remarks, 0, 0)
updateNotification("", 0, 0)
}
}
@@ -185,15 +157,17 @@ object NotificationManager {
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(): String {
val channelId = AppConfig.RAY_NG_CHANNEL_ID
val channelName = AppConfig.RAY_NG_CHANNEL_NAME
val chan = NotificationChannel(
channelId,
channelName, NotificationManager.IMPORTANCE_HIGH
)
chan.lightColor = Color.DKGRAY
chan.importance = NotificationManager.IMPORTANCE_NONE
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
getNotificationManager()?.createNotificationChannel(chan)
val service = getService() ?: return channelId
NotificationHelper.ensureNotificationChannel(
context = service,
channelId = channelId,
channelNameRes = R.string.notification_channel_service,
// Foreground-service notifications must remain visible; LOW is silent but valid.
importance = NotificationManager.IMPORTANCE_LOW,
) {
lightColor = Color.DKGRAY
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
return channelId
}
@@ -247,6 +221,70 @@ object NotificationManager {
text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
}
/**
* Updates the speed notification once.
* Queries traffic stats, separates proxy and direct, and updates the notification.
* @param lastZeroSpeed The previous zero speed state.
* @return The current zero speed state.
*/
private fun updateSpeedNotificationOnce(lastZeroSpeed: Boolean): Boolean {
val queryTime = System.currentTimeMillis()
val sinceLastQueryIn = (queryTime - lastQueryTime)
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
return lastZeroSpeed
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyUplink = 0L
var proxyDownlink = 0L
var directUplink = 0L
var directDownlink = 0L
CoreServiceManager.queryAllOutboundTrafficStats().forEach { stat ->
when {
stat.tag == AppConfig.TAG_DIRECT -> {
when (stat.direction) {
AppConfig.UPLINK -> directUplink += stat.value
AppConfig.DOWNLINK -> directDownlink += stat.value
}
}
// Accumulate stats for all proxy outbounds (including custom subscription tags)
stat.tag != AppConfig.TAG_BLOCKED -> {
when (stat.direction) {
AppConfig.UPLINK -> proxyUplink += stat.value
AppConfig.DOWNLINK -> proxyDownlink += stat.value
}
}
}
}
val proxyTotal = proxyUplink + proxyDownlink
val directTotal = directUplink + directDownlink
val zeroSpeed = proxyTotal + directTotal == 0L
if (!zeroSpeed || !lastZeroSpeed) {
val text = StringBuilder()
appendSpeedString(
text, AppConfig.TAG_PROXY,
proxyUplink / sinceLastQueryInSeconds,
proxyDownlink / sinceLastQueryInSeconds
)
appendSpeedString(
text, AppConfig.TAG_DIRECT,
directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directTotal)
}
lastQueryTime = queryTime
return zeroSpeed
}
/**
* Gets the service instance.
* @return The service instance.
@@ -254,4 +292,4 @@ object NotificationManager {
private fun getService(): Service? {
return CoreServiceManager.serviceControl?.get()?.getService()
}
}
}
@@ -0,0 +1,53 @@
package com.v2ray.ang.handler
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
internal data class StoredProfileReference(
val guid: String,
val subscriptionId: String?,
)
internal object OrphanProfileCleaner {
/**
* Finds profile payloads that are provably unreachable from the raw group indexes.
*
* A null subscription ID means that the profile payload could not be decoded and is
* preserved. A null server set means that a group index could not be decoded, in which
* case the entire classification returns null and no cleanup should run.
* Subscription metadata is deliberately not an input because SUB and SUB_IDS can be
* missing while the raw SUB_SERVERS_* indexes remain intact.
*/
fun findOrphans(
profiles: Collection<StoredProfileReference>,
indexedServersBySubscription: Map<String, Set<String>?>,
selectedServer: String?,
): Set<String>? {
if (profiles.isEmpty()) return emptySet()
if (indexedServersBySubscription.isEmpty() ||
indexedServersBySubscription.values.any { it == null }
) {
return null
}
val indexedServers = indexedServersBySubscription.values
.filterNotNull()
.flatten()
.toSet()
return profiles.mapNotNullTo(linkedSetOf()) { profile ->
if (profile.guid == selectedServer || profile.guid in indexedServers) {
return@mapNotNullTo null
}
val subscriptionId = profile.subscriptionId ?: return@mapNotNullTo null
val groupId = subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
if (!indexedServersBySubscription.containsKey(groupId)) {
return@mapNotNullTo null
}
profile.guid
}
}
}
@@ -0,0 +1,76 @@
package com.v2ray.ang.handler
import com.v2ray.ang.dto.entities.ProfileItem
internal object ProfileReplacement {
/**
* Finds the profile that should become selected after publishing a replacement batch.
* The first profile becomes selected when the store has no current selection.
*/
fun findSelectedReplacement(
profiles: Map<String, ProfileItem>,
currentSelection: String?,
selectedProfile: ProfileItem?,
): String? {
if (profiles.isEmpty()) return null
if (currentSelection.isNullOrBlank()) return profiles.keys.first()
if (selectedProfile == null) return null
if (selectedProfile.remarks.isNotBlank()) {
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.remarks, selectedProfile.remarks) &&
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
isSameText(candidate.password, selectedProfile.password)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.remarks, selectedProfile.remarks)
}?.key?.let { return it }
}
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
isSameText(candidate.password, selectedProfile.password)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server)
}?.key?.let { return it }
return profiles.keys.firstOrNull()
}
/**
* Finds replaced payloads that are safe to remove.
*
* A null cross-group reference set means that at least one raw group index could not
* be read. In that case deletion fails closed.
*/
fun findRemovablePayloads(
replacedServers: Collection<String>,
replacementServers: Set<String>,
protectedServer: String?,
serversReferencedByOtherGroups: Set<String>?,
): Set<String> {
if (serversReferencedByOtherGroups == null) return emptySet()
return replacedServers.filterTo(linkedSetOf()) { guid ->
guid != protectedServer &&
guid !in replacementServers &&
guid !in serversReferencedByOtherGroups
}
}
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
}
}
@@ -1,32 +1,58 @@
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_DYNAMIC_COLOR,
AppConfig.PREF_IS_BOOTED,
)
/**
* Called when a setting value changes.
* Triggers service restart if the key is not UI-only, and always refreshes UI tabs.
*/
fun notifySettingChanged(key: String) {
if (key !in uiOnlyKeys) {
makeRestartService()
}
makeSetupGroupTab()
}
fun makeRestartService() {
_restartService.value = true
restartService.set(true)
}
// Read and clear the restartService flag
fun consumeRestartService(): Boolean {
val v = _restartService.value
_restartService.value = false
return v
}
/**
* Atomically consumes the restart flag.
* @return true if a restart was requested, false otherwise.
*/
fun consumeRestartService(): Boolean =
restartService.compareAndSet(true, false)
// Mark reinitGroupTab as requiring tab reinitialization
fun makeSetupGroupTab() {
_setupGroupTab.value = true
setupGroupTab.set(true)
}
// Read and clear the reinitGroupTab flag
fun consumeSetupGroupTab(): Boolean {
val v = _setupGroupTab.value
_setupGroupTab.value = false
return v
}
/**
* Atomically consumes the setup-group-tab flag.
* @return true if UI refresh was requested, false otherwise.
*/
fun consumeSetupGroupTab(): Boolean =
setupGroupTab.compareAndSet(true, false)
}
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.AssetManager
import android.os.Build
import android.text.TextUtils
import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
@@ -12,14 +11,14 @@ import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.extension.moveItem
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
@@ -31,8 +30,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
object SettingsManager {
@@ -63,26 +60,25 @@ object SettingsManager {
/**
* Get preset routing rulesets.
* @param context The application context.
* @param index The index of the routing type.
* @param type The routing preset type.
* @return A mutable list of RulesetItem.
*/
private fun getPresetRoutingRulesets(context: Context, index: Int = 0): MutableList<RulesetItem>? {
val fileName = RoutingType.fromIndex(index).fileName
val assets = Utils.readTextFromAssets(context, fileName)
private fun getPresetRoutingRulesets(context: Context, type: RoutingType = RoutingType.WHITE): MutableList<RulesetItem>? {
val assets = Utils.readTextFromAssets(context, type.fileName)
if (TextUtils.isEmpty(assets)) {
return null
}
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java)?.toMutableList()
return JsonUtil.fromJsonSafe(assets, Array<RulesetItem>::class.java)?.toMutableList()
}
/**
* Reset routing rulesets from presets.
* @param context The application context.
* @param index The index of the routing type.
* @param type The routing preset type.
*/
fun resetRoutingRulesetsFromPresets(context: Context, index: Int) {
val rulesetList = getPresetRoutingRulesets(context, index) ?: return
fun resetRoutingRulesetsFromPresets(context: Context, type: RoutingType) {
val rulesetList = getPresetRoutingRulesets(context, type) ?: return
resetRoutingRulesetsCommon(rulesetList)
}
@@ -97,7 +93,7 @@ object SettingsManager {
}
try {
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java)?.toMutableList()
val rulesetList = JsonUtil.fromJsonSafe(content, Array<RulesetItem>::class.java)?.toMutableList()
if (rulesetList.isNullOrEmpty()) {
return false
}
@@ -180,7 +176,7 @@ object SettingsManager {
* @return True if bypassing LAN, false otherwise.
*/
fun routingRulesetsBypassLan(): Boolean {
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
if (vpnBypassLan == "1") {
return true
} else if (vpnBypassLan == "2") {
@@ -191,7 +187,7 @@ object SettingsManager {
val config = decodeServerConfig(guid) ?: return false
if (config.configType == EConfigType.CUSTOM) {
val raw = MmkvManager.decodeServerRaw(guid) ?: return false
val v2rayConfig = JsonUtil.fromJson(raw, V2rayConfig::class.java)
val v2rayConfig = JsonUtil.fromJsonSafe(raw, V2rayConfig::class.java)
val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any {
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
}
@@ -205,32 +201,6 @@ object SettingsManager {
return exist == true
}
/**
* Swap routing rulesets.
* @param fromPosition The position to swap from.
* @param toPosition The position to swap to.
*/
fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) {
val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) return
Collections.swap(rulesetList, fromPosition, toPosition)
MmkvManager.encodeRoutingRulesets(rulesetList)
}
/**
* Swap subscriptions.
* @param fromPosition The position to swap from.
* @param toPosition The position to swap to.
*/
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
val subsList = decodeSubsList()
if (subsList.isEmpty()) return
Collections.swap(subsList, fromPosition, toPosition)
MmkvManager.encodeSubsList(subsList)
}
/**
* Get server via remarks.
* @param remarks The remarks of the server.
@@ -246,6 +216,20 @@ object SettingsManager {
.firstOrNull { it.remarks == remarks }
}
/**
* Collects non-empty profile remarks while excluding specific config types.
*/
fun getProfileRemarks(excludeConfigTypes: Set<EConfigType> = setOf(EConfigType.CUSTOM)): List<String> {
return decodeAllServerList()
.asSequence()
.mapNotNull { guid -> decodeServerConfig(guid) }
.filter { profile -> profile.configType !in excludeConfigTypes }
.map { it.remarks.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
}
/**
* Removes the subscription.
* If there are no remaining subscriptions,
@@ -402,40 +386,6 @@ object SettingsManager {
return value.coerceIn(1, 128)
}
/**
* Get the locale.
* @return The locale.
*/
fun getLocale(): Locale {
val langCode =
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
val language = Language.fromCode(langCode)
return when (language) {
Language.AUTO -> Utils.getSysLocale()
Language.ENGLISH -> Locale.ENGLISH
Language.CHINA -> Locale.CHINA
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
Language.VIETNAMESE -> Locale.forLanguageTag("vi")
Language.RUSSIAN -> Locale.forLanguageTag("ru")
Language.PERSIAN -> Locale.forLanguageTag("fa")
Language.ARABIC -> Locale.forLanguageTag("ar")
Language.BANGLA -> Locale.forLanguageTag("bn")
Language.BAKHTIARI -> Locale.forLanguageTag("bqi-IR")
}
}
/**
* Set night mode.
*/
fun setNightMode() {
when (MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0")) {
"0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
"1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
}
/**
* Retrieves the currently selected VPN interface address configuration.
* This method reads the user's preference for VPN interface addressing and returns
@@ -473,6 +423,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.
*/
@@ -510,9 +467,15 @@ object SettingsManager {
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_PING_INTERVAL, AppConfig.OBSERVATORY_LEAST_PING_INTERVAL)
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_INTERVAL, AppConfig.OBSERVATORY_LEAST_LOAD_INTERVAL)
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_METHOD, AppConfig.OBSERVATORY_LEAST_LOAD_METHOD)
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_SAMPLING, AppConfig.OBSERVATORY_LEAST_LOAD_SAMPLING)
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT, AppConfig.OBSERVATORY_LEAST_LOAD_TIMEOUT)
}
private fun ensureDefaultValue(key: String, default: String) {
@@ -571,7 +534,7 @@ object SettingsManager {
return
}
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
val guids = JsonUtil.fromJsonSafe(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true)
return
}
@@ -608,10 +571,10 @@ object SettingsManager {
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
// Move top
// Move to the top
val subsList = decodeSubsList()
if (subsList.count() > 1) {
swapSubscriptions(0, subsList.count() - 1)
if (subsList.moveItem(subsList.lastIndex, 0)) {
MmkvManager.encodeSubsList(subsList)
}
}
}
@@ -2,11 +2,10 @@ package com.v2ray.ang.handler
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
@@ -14,28 +13,10 @@ 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
}
data class RemoteEndpointInfo(
val country: String?,
val ipAddress: String?,
)
/**
* Measures the time taken to establish a TCP connection to a given URL and port.
@@ -44,43 +25,35 @@ 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? {
fun getRemoteIPInfo(): RemoteEndpointInfo? {
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
@@ -88,8 +61,16 @@ object SpeedtestManager {
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
if (httpPort == 0) return null
val content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: return null
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
val content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
) ?: return null
val ipInfo = JsonUtil.fromJsonSafe(content, IPAPIInfo::class.java) ?: return null
val ip = listOf(
ipInfo.ip,
@@ -105,6 +86,9 @@ object SpeedtestManager {
ipInfo.location?.country_code
).firstOrNull { !it.isNullOrBlank() }
return "(${country ?: "unknown"}) ${ip ?: "unknown"}"
return RemoteEndpointInfo(
country = country,
ipAddress = ip,
)
}
}
@@ -0,0 +1,121 @@
package com.v2ray.ang.handler
import com.v2ray.ang.dto.entities.SubscriptionUsageInfo
import com.v2ray.ang.util.Utils
import java.net.URI
import java.net.URLDecoder
import java.util.Locale
/**
* Parses the account information subscription panels put in the response headers of the
* subscription URL (the de-facto standard shared by 3x-ui, Marzban, Remnawave, Clash, v2rayN,
* Hiddify and others):
*
* - `Subscription-Userinfo: upload=<bytes>; download=<bytes>; total=<bytes>; expire=<unix seconds>`
* - `Profile-Title: <text>` or `base64:<base64 text>`
* - `Support-Url: <link>` (usually a Telegram link)
* - `Profile-Web-Page-Url: <link>` (the panel's subscription information page)
*
* Pure functions only, so the logic is covered by plain JVM tests.
*/
object SubscriptionInfoParser {
private const val BASE64_PREFIX = "base64:"
private const val MAX_TITLE_LENGTH = 100
private const val MAX_URL_LENGTH = 2048
/** Values at or above this are epoch milliseconds, not seconds (year 5138 in seconds). */
private const val MILLIS_THRESHOLD = 100_000_000_000L
private val SAFE_SCHEMES = setOf("http", "https", "tg")
private val TELEGRAM_HOSTS = setOf("t.me", "telegram.me", "telegram.dog", "telegram.org")
/**
* Builds usage info from raw header values.
*
* @return null when the panel sent none of the supported headers, so the UI shows nothing.
*/
fun parse(
userInfo: String?,
profileTitle: String?,
supportUrl: String?,
webPageUrl: String?,
decodeBase64: (String) -> String = Utils::decode,
): SubscriptionUsageInfo? {
val fields = parseUserInfoFields(userInfo)
val title = decodeTitle(profileTitle, decodeBase64)
val support = sanitizeLink(supportUrl)
val webPage = sanitizeLink(webPageUrl)
if (fields == null && title == null && support == null && webPage == null) return null
return SubscriptionUsageInfo(
upload = fields?.get("upload") ?: 0,
download = fields?.get("download") ?: 0,
total = fields?.get("total") ?: 0,
expire = normalizeExpire(fields?.get("expire") ?: 0),
title = title,
supportUrl = support,
webPageUrl = webPage,
)
}
/**
* The subscription URL itself answers with the panel's information page when it is opened in
* a browser (3x-ui, Marzban), so it is the fallback target of the "info page" button.
*/
fun withWebPageFallback(info: SubscriptionUsageInfo, subscriptionUrl: String): SubscriptionUsageInfo {
if (info.webPageUrl != null) return info
return info.copy(webPageUrl = sanitizeLink(subscriptionUrl))
}
/** Returns [raw] when it is an http(s) or tg:// link that is safe to hand to ACTION_VIEW. */
fun sanitizeLink(raw: String?): String? {
val value = raw?.trim().orEmpty()
if (value.isEmpty() || value.length > MAX_URL_LENGTH) return null
val uri = runCatching { URI(value) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return null
if (scheme !in SAFE_SCHEMES) return null
if (scheme != "tg" && uri.host.isNullOrEmpty()) return null
return value
}
/** True for links that open in the Telegram app. */
fun isTelegramLink(url: String?): Boolean {
val uri = runCatching { URI(url.orEmpty().trim()) }.getOrNull() ?: return false
val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return false
if (scheme == "tg") return true
return uri.host?.lowercase(Locale.ROOT) in TELEGRAM_HOSTS
}
private fun parseUserInfoFields(header: String?): Map<String, Long>? {
if (header.isNullOrBlank()) return null
val result = HashMap<String, Long>()
for (part in header.split(';')) {
val separator = part.indexOf('=')
if (separator <= 0) continue
val key = part.substring(0, separator).trim().lowercase(Locale.ROOT)
val raw = part.substring(separator + 1).trim()
val number = raw.toLongOrNull() ?: raw.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong()
if (number != null) result[key] = number.coerceAtLeast(0)
}
return result.takeIf { it.isNotEmpty() }
}
private fun normalizeExpire(value: Long): Long =
if (value >= MILLIS_THRESHOLD) value / 1000 else value
private fun decodeTitle(raw: String?, decodeBase64: (String) -> String): String? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
val decoded = when {
value.startsWith(BASE64_PREFIX, ignoreCase = true) ->
decodeBase64(value.substring(BASE64_PREFIX.length).trim())
value.contains('%') ->
runCatching { URLDecoder.decode(value, "UTF-8") }.getOrDefault(value)
else -> value
}
return decoded.trim().take(MAX_TITLE_LENGTH).ifEmpty { null }
}
}
@@ -12,11 +12,9 @@ import androidx.work.multiprocess.RemoteWorkManager
import androidx.work.workDataOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.NotificationHelper
import java.util.concurrent.TimeUnit
object SubscriptionUpdater {
@@ -44,14 +42,15 @@ object SubscriptionUpdater {
ExistingPeriodicWorkPolicy.KEEP
}
MmkvManager.decodeSubscriptions().forEach { sub ->
scheduleOne(
context = context,
subId = sub.guid,
shouldRun = sub.subscription.autoUpdate,
existingWorkPolicy = existingWorkPolicy
)
}
MmkvManager.decodeSubscriptions()
.filter { it.subscription.autoUpdate && it.subscription.url.isNotEmpty() }
.forEach { sub ->
scheduleOne(
context = context,
subId = sub.guid,
existingWorkPolicy = existingWorkPolicy
)
}
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: sync complete forceReschedule=$forceReschedule"
@@ -63,11 +62,9 @@ object SubscriptionUpdater {
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
*/
fun syncOne(context: Context = AngApplication.application, subId: String) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
scheduleOne(
context = context,
subId = subId,
shouldRun = subItem.autoUpdate,
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
)
}
@@ -81,6 +78,17 @@ object SubscriptionUpdater {
.cancelUniqueWork(taskName(subId))
}
/**
* Update the last updated timestamp and reschedule the task.
* This is used to reset the periodic timer and prevent rapid rescheduling loops.
*/
fun updateLastUpdatedAndReschedule(context: Context = AngApplication.application, subId: String) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
subItem.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(subId, subItem)
syncOne(context, subId)
}
// -------------------------------------------------------------------------
// Internal scheduling logic
// -------------------------------------------------------------------------
@@ -90,17 +98,20 @@ object SubscriptionUpdater {
private fun scheduleOne(
context: Context,
subId: String,
shouldRun: Boolean,
existingWorkPolicy: ExistingPeriodicWorkPolicy
) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
val rw = RemoteWorkManager.getInstance(context)
if (!shouldRun) {
rw.cancelUniqueWork(taskName(subId))
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for $subId")
if (!subItem.autoUpdate) {
cancelOne(context, subId)
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for ${subItem.remarks}")
return
}
val subItem = MmkvManager.decodeSubscription(subId) ?: return
if (subItem.url.isEmpty()) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: url isEmpty for ${subItem.remarks}, skip")
return
}
val intervalMinutes = maxOf(
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
@@ -111,12 +122,17 @@ object SubscriptionUpdater {
val lastUpdated = subItem.lastUpdated
val intervalMillis = intervalMinutes * 60 * 1000L
val now = System.currentTimeMillis()
val initialDelayMillis = if (lastUpdated <= 0L) {
var initialDelayMillis = if (lastUpdated <= 0L) {
0L
} else {
maxOf(0L, lastUpdated + intervalMillis - now)
}
// Add a small floor to initial delay to prevent rapid rescheduling loops.
if (existingWorkPolicy == ExistingPeriodicWorkPolicy.REPLACE && initialDelayMillis < 5000L) {
initialDelayMillis = 5000L
}
val request = PeriodicWorkRequestBuilder<UpdateTask>(intervalMinutes, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
@@ -136,7 +152,7 @@ object SubscriptionUpdater {
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
"SubscriptionUpdater: scheduled [${subItem.remarks}] interval=${intervalMinutes}min " +
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
)
}
@@ -153,41 +169,21 @@ object SubscriptionUpdater {
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
val subId = inputData.getString(KEY_SUB_ID)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update starting: $subId")
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update starting via Service: $subId")
if (subId.isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
return Result.success()
}
val subItem = MmkvManager.decodeSubscription(subId)
if (subItem == null) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
return Result.success()
}
updateLastUpdatedAndReschedule(applicationContext, subId)
if (!subItem.autoUpdate) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: auto-update disabled for $subId, skip")
return Result.success()
}
val sub = SubscriptionCache(subId, subItem)
// Notify about update start
NotificationHelper.notify(
NotificationChannelType.SUBSCRIPTION_UPDATE,
MessageHelper.sendMsg2SubscriptionService(
applicationContext,
applicationContext.getString(R.string.title_pref_auto_update_subscription),
"Updating ${sub.subscription.remarks}"
SubscriptionUpdateMessage(AppConfig.MSG_SUB_UPDATE_START, true, listOf(subId))
)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
AngConfigManager.updateConfigViaSub(sub)
// Clear notification
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
return Result.success()
}
}
}
}
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.dto.GitHubRelease
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
@@ -23,19 +24,32 @@ object UpdateCheckerManager {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var response = HttpUtil.getUrlContent(url, 5000)
var response = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000
)
)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword)
response = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
)
?: throw IllegalStateException("Failed to get response")
}
val latestRelease = if (includePreRelease) {
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
JsonUtil.fromJsonSafe(response, Array<GitHubRelease>::class.java)
?.firstOrNull()
?: throw IllegalStateException("No pre-release found")
} else {
JsonUtil.fromJson(response, GitHubRelease::class.java)
JsonUtil.fromJsonSafe(response, GitHubRelease::class.java)
}
if (latestRelease == null) {
return@withContext CheckUpdateResult(hasUpdate = false)
@@ -1,7 +1,7 @@
package com.v2ray.ang.handler
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -1,68 +0,0 @@
package com.v2ray.ang.helper
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class CustomDividerItemDecoration(
private val divider: Drawable,
private val orientation: Int
) : RecyclerView.ItemDecoration() {
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (orientation == RecyclerView.VERTICAL) {
drawVerticalDividers(canvas, parent)
} else {
drawHorizontalDividers(canvas, parent)
}
}
private fun drawVerticalDividers(canvas: Canvas, parent: RecyclerView) {
val left = parent.paddingLeft
val right = parent.width - parent.paddingRight
val childCount = parent.childCount
for (i in 0 until childCount - 1) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val top = child.bottom + params.bottomMargin
val bottom = top + divider.intrinsicHeight
divider.setBounds(left, top, right, bottom)
divider.draw(canvas)
}
}
private fun drawHorizontalDividers(canvas: Canvas, parent: RecyclerView) {
val top = parent.paddingTop
val bottom = parent.height - parent.paddingBottom
val childCount = parent.childCount
for (i in 0 until childCount - 1) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val left = child.right + params.rightMargin
val right = left + divider.intrinsicWidth
divider.setBounds(left, top, right, bottom)
divider.draw(canvas)
}
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
if (orientation == RecyclerView.VERTICAL) {
outRect.set(0, 0, 0, divider.intrinsicHeight)
} else {
outRect.set(0, 0, divider.intrinsicWidth, 0)
}
}
}
@@ -1,11 +1,12 @@
package com.v2ray.ang.helper
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
@@ -15,14 +16,14 @@ import com.v2ray.ang.util.LogUtil
* Helper for choosing and creating files using Android Storage Access Framework.
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
*/
class FileChooserHelper(private val activity: AppCompatActivity) {
class FileChooserHelper(private val activity: ComponentActivity) {
private var fileChooserCallback: ((Uri?) -> Unit)? = null
private var documentCreateCallback: ((Uri?) -> Unit)? = null
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
if (result.resultCode == Activity.RESULT_OK && uri != null) {
fileChooserCallback?.invoke(uri)
} else {
fileChooserCallback?.invoke(null)
@@ -85,4 +86,4 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
documentCreateCallback = null
}
}
}
}
@@ -1,53 +0,0 @@
/*
* Copyright (C) 2015 Paul Burke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.v2ray.ang.helper
/**
* Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
*
* @author Paul Burke (ipaulpro)
*/
interface ItemTouchHelperAdapter {
/**
* Called when an item has been dragged far enough to trigger a move. This is called every time
* an item is shifted, and **not** at the end of a "drop" event.<br></br>
* <br></br>
* Implementations should call [RecyclerView.Adapter.notifyItemMoved] after
* adjusting the underlying data to reflect this move.
*
* @param fromPosition The start position of the moved item.
* @param toPosition Then resolved position of the moved item.
* @return True if the item was moved to the new adapter position.
* @see RecyclerView.getAdapterPositionFor
* @see RecyclerView.ViewHolder.getAdapterPosition
*/
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
fun onItemMoveCompleted()
/**
* Called when an item has been dismissed by a swipe.<br></br>
* <br></br>
* Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after
* adjusting the underlying data to reflect this removal.
*
* @param position The position of the item dismissed.
* @see RecyclerView.getAdapterPositionFor
* @see RecyclerView.ViewHolder.getAdapterPosition
*/
fun onItemDismiss(position: Int)
}
@@ -1,38 +0,0 @@
/*
* Copyright (C) 2015 Paul Burke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.v2ray.ang.helper
import androidx.recyclerview.widget.ItemTouchHelper
/**
* Interface to notify an item ViewHolder of relevant callbacks from [ ].
*
* @author Paul Burke (ipaulpro)
*/
interface ItemTouchHelperViewHolder {
/**
* Called when the [ItemTouchHelper] first registers an item as being moved or swiped.
* Implementations should update the item view to indicate it's active state.
*/
fun onItemSelected()
/**
* Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
* state should be cleared.
*/
fun onItemClear()
}
@@ -0,0 +1,169 @@
package com.v2ray.ang.helper
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.service.CoreTestService
import com.v2ray.ang.service.SubscriptionUpdateService
import com.v2ray.ang.util.LogUtil
import java.io.Serializable
object MessageHelper {
const val EXTRA_REQUEST_ID = "requestId"
/**
* Sends a message to the service.
*
* @param ctx The context.
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2Service(ctx: Context, what: Int, content: Serializable) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
}
/**
* Sends an ordered service message and reports whether a daemon receiver handled it.
* With no running daemon, the initial canceled result reaches [onResult] unchanged.
*/
internal fun sendMsg2ServiceForResult(
ctx: Context,
what: Int,
content: Serializable,
onResult: (handled: Boolean) -> Unit,
) {
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
onResult(resultCode == Activity.RESULT_OK)
}
}
try {
ctx.sendOrderedBroadcast(
messageIntent(AppConfig.BROADCAST_ACTION_SERVICE, what, content),
null,
resultReceiver,
null,
Activity.RESULT_CANCELED,
null,
null,
)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send ordered message to service", e)
onResult(false)
}
}
/**
* Sends a message to the UI.
*
* @param ctx The context.
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable, requestId: String? = null) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content, requestId)
}
/**
* Sends a message to the test service.
*
* @param ctx The context.
* @param message The test service message containing key, subscriptionId, and serverGuids.
*/
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage, requestId: String? = null) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, CoreTestService::class.java)
intent.putExtra("content", message)
requestId?.let { intent.putExtra(EXTRA_REQUEST_ID, it) }
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(ctx, intent)
} else {
ctx.startService(intent)
}
}
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
// Do not wake up service just to cancel; stop only if it is already running.
ctx.stopService(intent)
}
else -> {
ctx.startService(intent)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
if (message.key == AppConfig.MSG_MEASURE_CONFIG_START) {
sendMsg2UI(ctx, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
}
}
}
/**
* Sends a message to the subscription service.
*
* @param ctx The context.
* @param message The subscription service message containing key and subId.
*/
fun sendMsg2SubscriptionService(ctx: Context, message: SubscriptionUpdateMessage) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, SubscriptionUpdateService::class.java)
intent.putExtra("content", message)
when (message.key) {
AppConfig.MSG_SUB_UPDATE_START -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(ctx, intent)
} else {
ctx.startService(intent)
}
}
AppConfig.MSG_SUB_UPDATE_CANCEL -> {
ctx.stopService(intent)
}
else -> {
ctx.startService(intent)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to subscription service", e)
}
}
/**
* Sends a message with the specified action.
*
* @param ctx The context.
* @param action The action string.
* @param what The message identifier.
* @param content The message content.
*/
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable, requestId: String? = null) {
try {
ctx.sendBroadcast(messageIntent(action, what, content).apply {
requestId?.let { putExtra(EXTRA_REQUEST_ID, it) }
})
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
}
}
private fun messageIntent(action: String, what: Int, content: Serializable): Intent =
Intent(action).apply {
`package` = AppConfig.ANG_PACKAGE
putExtra("key", what)
putExtra("content", content)
}
}
@@ -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
@@ -6,9 +6,11 @@ import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Build
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.handler.AppLocaleManager
/**
* Unified notification helper for different notification channels.
@@ -55,13 +57,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)
@@ -76,15 +79,18 @@ object NotificationHelper {
* @param channelType The notification channel type
* @param title The notification title
* @param content The notification content text
* @param action An optional action retained when the notification is updated
*/
fun startForeground(
service: Service,
channelType: NotificationChannelType,
title: String,
content: String
content: String,
action: NotificationCompat.Action? = null
) {
ensureChannelCreated(channelType, service)
val builder = buildNotificationBuilder(channelType, service, title, content)
val builder = buildNotificationBuilder(channelType, service, title, content, action)
builderCache[channelType.notificationId] = builder
service.startForeground(channelType.notificationId, builder.build())
}
@@ -120,27 +126,51 @@ object NotificationHelper {
return cachedNotificationManager!!
}
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) =
ensureNotificationChannel(
context = context,
channelId = channelType.channelId,
channelNameRes = channelType.channelNameRes,
importance = NotificationManager.IMPORTANCE_LOW,
)
/**
* Creates a channel or updates only its localized name.
*
* Android lets apps rename an existing channel, while its behavior remains under user control.
*/
internal fun ensureNotificationChannel(
context: Context,
channelId: String,
@StringRes channelNameRes: Int,
importance: Int,
configureNewChannel: NotificationChannel.() -> Unit = {},
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (notificationManager.getNotificationChannel(channelType.channelId) != null) return
val channel = NotificationChannel(
channelType.channelId,
channelType.channelName,
NotificationManager.IMPORTANCE_LOW
).apply {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val localizedName = AppLocaleManager.localizedContext(context).getString(channelNameRes)
val existingChannel = notificationManager.getNotificationChannel(channelId)
if (existingChannel == null) {
NotificationChannel(channelId, localizedName, importance)
.apply {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
configureNewChannel()
}
.also(notificationManager::createNotificationChannel)
} else if (existingChannel.name.toString() != localizedName) {
existingChannel.name = localizedName
notificationManager.createNotificationChannel(existingChannel)
}
notificationManager.createNotificationChannel(channel)
}
private fun buildNotificationBuilder(
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
content: String,
action: NotificationCompat.Action? = null
): NotificationCompat.Builder {
val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
channelType.channelId
@@ -157,6 +187,6 @@ object NotificationHelper {
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.apply { action?.let(::addAction) }
}
}
@@ -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> =
@@ -36,11 +36,14 @@ class PermissionHelper(private val activity: AppCompatActivity) {
if (isGranted) {
onGranted()
} else {
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
val message = activity.getString(
R.string.toast_permission_denied_for,
activity.getString(permissionType.getLabelRes())
)
activity.toast(message)
}
}
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)
}
}
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more