Compare commits

...
60 Commits
Author SHA1 Message Date
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
129 changed files with 3746 additions and 1887 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.
+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
out-file-path: V2rayNG/app/libs/
- name: Setup Java
uses: actions/setup-java@v5.7.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: '21'
+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
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 745
versionName = "2.3.5"
versionCode = 749
versionName = "2.3.9"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
+15 -11
View File
@@ -153,6 +153,7 @@
android:exported="false" />
<activity
android:name=".ui.userasset.UserAssetUrlActivity"
android:windowSoftInputMode="stateUnchanged|adjustResize"
android:exported="false" />
<activity
@@ -166,19 +167,19 @@
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.shortcut.ScStartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.shortcut.ScStopActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
@@ -209,6 +210,9 @@
<activity
android:name=".ui.AboutActivity"
android:exported="false" />
<activity
android:name=".ui.TranslatorsActivity"
android:exported="false" />
<service
android:name=".service.CoreVpnService"
@@ -217,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" />
@@ -235,7 +239,7 @@
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" />
@@ -246,7 +250,7 @@
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" />
@@ -256,7 +260,7 @@
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" />
@@ -266,7 +270,7 @@
android:name=".service.SubscriptionUpdateService"
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" />
@@ -275,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" />
@@ -303,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>
@@ -324,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 -87
View File
@@ -1,102 +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",
"quic"
]
}
}
],
"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",
"streamSettings": {
"sockopt": {
"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,119 +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",
"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": {
"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",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP"
}
},
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": []
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
"hosts": {},
"servers": []
"hosts": {},
"servers": []
}
}
}
@@ -166,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
@@ -181,6 +189,7 @@ object AppConfig {
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
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
@@ -193,7 +202,6 @@ object AppConfig {
/** Notification channel IDs and names. */
// Use a new ID because Android does not let an app raise an existing channel's importance.
const val RAY_NG_CHANNEL_ID = "CORE_M_CH_ID_V2"
const val RAY_NG_CHANNEL_NAME = "Core Background Service"
/** Protocols Scheme **/
const val VMESS = "vmess://"
@@ -113,7 +113,13 @@ object CoreConfigManager {
}
} else {
json.remove("stats")
json.remove("policy")
// Keep user-defined policy levels, only strip the stats-related system block
json.get("policy")?.takeIf { it.isJsonObject }?.asJsonObject?.let { policy ->
policy.remove("system")
if (policy.entrySet().isEmpty()) {
json.remove("policy")
}
}
}
if (!needTun()) {
@@ -511,7 +517,7 @@ object CoreConfigManager {
inbound1.listen = AppConfig.LOOPBACK
}
inbound1.port = socksPort
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
if (socksUsername != null && socksPassword != null) {
inbound1.settings?.auth = "password"
inbound1.settings?.accounts = listOf(
@@ -709,7 +715,7 @@ object CoreConfigManager {
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
v2rayConfig.stats = null
v2rayConfig.policy = null
v2rayConfig.policy?.system = null
}
}
@@ -1052,7 +1058,7 @@ object CoreConfigManager {
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
*/
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "1") {
return
}
@@ -62,7 +62,7 @@ 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?.flow?.isNotEmpty() == true) {
outbound.mux?.concurrency = -1
@@ -252,15 +252,33 @@ object CoreOutboundBuilder {
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 (rawDNS.size == 1 && rawDNS[0] == "local") {
rawDNS
} else 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 = 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() }
}
@@ -667,7 +685,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
@@ -18,6 +18,7 @@ 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
@@ -30,9 +31,13 @@ import com.v2ray.ang.service.NetworkMonitor
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import com.v2ray.ang.extension.delay
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
@@ -47,6 +52,7 @@ object CoreServiceManager {
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
@@ -184,6 +190,7 @@ object CoreServiceManager {
* @return True if the core was stopped successfully, false otherwise.
*/
fun stopCoreLoop(): Boolean {
connectionTestScope.coroutineContext.cancelChildren()
val service = getService() ?: return false
networkMonitor?.unregister()
@@ -254,6 +261,7 @@ object CoreServiceManager {
val tunFd = currentVpnInterface
isReloading = true
connectionTestScope.coroutineContext.cancelChildren()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
coreController.stopLoop()
@@ -308,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 (!isRunning()) {
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 = ""
@@ -325,6 +335,7 @@ object CoreServiceManager {
errorStr = e.message?.substringAfter("\":").orEmpty()
}
if (time == -1L) {
ensureActive()
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
@@ -333,25 +344,25 @@ object CoreServiceManager {
}
}
ensureActive()
val endpoint = if (time >= 0) SpeedtestManager.getRemoteIPInfo() else null
val result = ConnectionTestResult(
delayMillis = time,
errorMessage = errorStr,
country = endpoint?.country,
ipAddress = endpoint?.ipAddress,
)
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result)
// Only fetch IP info if the delay test was successful
if (time >= 0) {
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
MessageHelper.sendMsg2UI(
service,
AppConfig.MSG_MEASURE_DELAY_RESULT,
result.copy(
country = ip.country,
ipAddress = ip.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)
}
}
}
@@ -489,7 +500,8 @@ object CoreServiceManager {
}
AppConfig.MSG_MEASURE_DELAY -> {
measureV2rayDelay()
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
measureV2rayDelay(intent.getStringExtra("content").orEmpty())
}
}
@@ -2,6 +2,5 @@ package com.v2ray.ang.dto
data class LocateTarget(
val groupId: String,
val groupIndex: Int,
val itemPosition: Int
)
val serverGuid: String,
)
@@ -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
@@ -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()
}
}
}
@@ -98,6 +98,7 @@ data class V2rayConfig(
val peers: List<WireGuardBean>? = null,
var reserved: List<Int>? = null,
var mtu: Int? = null,
var remoteDNS: List<String>? = null,
var domainStrategy: String? = null,
) {
data class WireGuardBean(
@@ -7,7 +7,7 @@ import com.v2ray.ang.enums.NetworkType
@Suppress("PropertyName")
data class V2rayNShareItem(
// val IndexId: String?,
val IndexId: String?,
val ConfigType: Int?,
// val CoreType: Int?,
val ConfigVersion: Int?,
@@ -56,6 +56,7 @@ data class ProfileItem(
var localAddress: String? = null,
var reserved: String? = null,
var mtu: Int? = null,
var remoteDNS: String? = null,
var obfsPassword: String? = null,
var portHopping: String? = null,
@@ -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
)
}
}
@@ -7,111 +7,20 @@ import android.widget.Toast
import com.v2ray.ang.ui.compose.AppSnackbarManager
import com.v2ray.ang.ui.compose.ToastType
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toast(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.NORMAL) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toast(message: CharSequence) {
dispatchMessage(message, ToastType.NORMAL) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastSuccess(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.SUCCESS) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastSuccess(message: CharSequence) {
dispatchMessage(message, ToastType.SUCCESS) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastError(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.ERROR) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
dispatchMessage(message, ToastType.ERROR) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastInfo(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.INFO) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastInfo(message: CharSequence) {
dispatchMessage(message, ToastType.INFO) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private inline fun runOnMain(crossinline block: () -> Unit) {
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
Handler(Looper.getMainLooper()).post { block() }
mainHandler.post(block)
}
}
private inline fun dispatchMessage(
private fun Context.dispatchMessage(
message: CharSequence,
type: ToastType,
long: Boolean = false,
crossinline fallback: () -> Unit
long: Boolean = false
) {
val handledBySnackbar = AppSnackbarManager.show(
message = message,
@@ -119,6 +28,90 @@ private inline fun dispatchMessage(
long = long
)
if (!handledBySnackbar) {
runOnMain { fallback() }
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)
}
@@ -3,20 +3,46 @@ 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(str: String): ProfileItem? {
try {
val jsonBase64Payload = str.substringAfterLast('/')
val jsonPayload = Utils.decode(jsonBase64Payload)
val v2rayNShareItem = JsonUtil.fromJson(jsonPayload, V2rayNShareItem::class.java)
return v2rayNShareItem?.toProfileItem()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN format", e)
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 null
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
}
}
@@ -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)
}
@@ -48,7 +48,6 @@ object AngConfigManager {
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
AppConfig.HY2 to Hysteria2Fmt::parse,
AppConfig.V2RAYNFMTS to V2rayNFmt::parse
)
}
@@ -249,25 +248,34 @@ object AngConfigManager {
// 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)
}
}
}
if (configs.isNotEmpty()) {
val v2raynConfigs = V2rayNFmt.parse(v2raynLines, subid)
val allConfigs = v2raynConfigs + configs
if (allConfigs.isNotEmpty()) {
commitProfiles(
configs = configs.map(::ParsedProfile),
configs = allConfigs.map(::ParsedProfile),
subid = subid,
append = append,
)
}
return configs.size
return allConfigs.size
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
@@ -420,12 +428,6 @@ object AngConfigManager {
config.subscriptionId = subid
config.description = generateDescription(config)
if (str.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)
&& config.policyGroupSubscriptionId == "self"
) {
config.policyGroupSubscriptionId = subid
}
return config
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
@@ -20,6 +20,7 @@ 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
@@ -349,7 +350,9 @@ object MmkvManager {
val serverList = if (append) {
decodeServerList(subscriptionId)
} else {
mutableListOf()
replacedServers.filter { guid ->
decodeServerConfig(guid)?.configType?.isGroupType() == true
}.toMutableList()
}
val indexedServers = serverList.toHashSet()
profiles.keys.forEach { guid ->
@@ -373,7 +376,7 @@ object MmkvManager {
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
val removablePayloads = ProfileReplacement.findRemovablePayloads(
replacedServers = replacedServers,
replacementServers = profiles.keys,
replacementServers = serverList.toSet(),
protectedServer = protectedServer,
serversReferencedByOtherGroups = referencedByOtherGroups,
)
@@ -718,7 +721,8 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJsonSafe(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()
}
}
@@ -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
@@ -15,13 +14,14 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.delay
import com.v2ray.ang.extension.toSpeedString
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 com.v2ray.ang.extension.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.min
@@ -157,12 +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
// Foreground-service notifications must remain visible; LOW is silent but valid.
val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
chan.lightColor = Color.DKGRAY
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
}
@@ -176,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") {
@@ -467,7 +467,7 @@ 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")
@@ -16,7 +16,7 @@ import com.v2ray.ang.util.LogUtil
import java.io.Serializable
object MessageHelper {
const val EXTRA_REQUEST_ID = "requestId"
/**
* Sends a message to the service.
@@ -67,8 +67,8 @@ object MessageHelper {
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content)
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable, requestId: String? = null) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content, requestId)
}
/**
@@ -77,11 +77,12 @@ object MessageHelper {
* @param ctx The context.
* @param message The test service message containing key, subscriptionId, and serverGuids.
*/
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
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) {
@@ -102,6 +103,9 @@ object MessageHelper {
}
} 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)
}
}
}
@@ -146,9 +150,11 @@ object MessageHelper {
* @param what The message identifier.
* @param content The message content.
*/
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable, requestId: String? = null) {
try {
ctx.sendBroadcast(messageIntent(action, what, content))
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)
}
@@ -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.
@@ -124,20 +126,43 @@ 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(
@@ -6,7 +6,6 @@ import com.v2ray.ang.root.RootManager.refresh
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
/**
* Detects whether the device grants root (`su`) access.
@@ -43,21 +42,17 @@ object RootManager {
private fun probe(): Boolean {
return try {
val process = ProcessBuilder("su", "-c", "id -u")
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }.trim()
val finished = process.waitFor(10, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
val result = RootProcessRunner.run(listOf("su", "-c", "id -u"), 10000, 1024)
if (result.code == -1) {
LogUtil.w(AppConfig.TAG, "RootManager: su probe timed out")
return false
}
val isRoot = process.exitValue() == 0 && output.lineSequence().lastOrNull()?.trim() == "0"
val isRoot = result.code == 0 && result.output.lineSequence().filter { it.isNotBlank() }.lastOrNull()?.trim() == "0"
LogUtil.i(AppConfig.TAG, "RootManager: root available = $isRoot")
isRoot
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "RootManager: no root access (${e.message})")
if (e is InterruptedException) Thread.currentThread().interrupt()
LogUtil.w(AppConfig.TAG, "RootManager: root probe failed", e)
false
}
}
@@ -0,0 +1,41 @@
package com.v2ray.ang.root
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/** Owns one bounded root command. Neither inherited stdout nor output volume extends its deadline. */
internal object RootProcessRunner {
data class Outcome(val code: Int, val output: String)
fun run(command: List<String>, timeoutMillis: Long, outputLimit: Int = 65536): Outcome {
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val captured = ByteArrayOutputStream()
val buffer = ByteArray(4096)
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis)
var code = -1
try {
// Process.waitFor(timeout, unit) is API 26. Polling supports API 24–25 too.
// Read only available bytes: a detached helper may retain stdout after su exits.
while (System.nanoTime() < deadline) {
val available = process.inputStream.available()
if (available > 0) {
val size = process.inputStream.read(buffer, 0, minOf(buffer.size, available))
val remaining = outputLimit - captured.size()
if (size > 0 && remaining > 0) captured.write(buffer, 0, minOf(size, remaining))
}
try {
code = process.exitValue()
if (process.inputStream.available() == 0) break
} catch (_: IllegalThreadStateException) {
if (available == 0) Thread.sleep(10)
}
}
} finally {
if (code == -1) process.destroy()
process.inputStream.close()
process.outputStream.close()
process.errorStream.close()
}
return Outcome(code, captured.toString("UTF-8"))
}
}
@@ -2,6 +2,7 @@ package com.v2ray.ang.root
import android.content.Context
import android.os.Process
import android.util.AtomicFile
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
@@ -112,15 +113,25 @@ object RootProxyManager {
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val port = SettingsManager.getSocksPort()
val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR)
if (!runDir.isDirectory && !runDir.mkdirs()) {
LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to create runtime directory at ${runDir.absolutePath}")
return null
}
val pidFile = File(runDir, "tun2socks.pid").absolutePath
val logFile = File(runDir, "tun2socks.log").absolutePath
val cfgFile = File(runDir, "tun2socks.yml").absolutePath
val cfgFile = File(runDir, "tun2socks.yml")
val oomGuardPid = File(runDir, "oomguard.pid").absolutePath
val ipv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)
val lanShare = forceLanShare || MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
val corePid = Process.myPid()
val config = buildHevConfig(socksUsername, socksPassword, port, ipv6)
if (!writeHevConfig(cfgFile, config)) {
return null
}
val cfgPath = cfgFile.absolutePath
// Per-app proxy/bypass (mirrors what VpnService does via allowed/disallowed apps).
val perAppEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
@@ -141,15 +152,9 @@ object RootProxyManager {
appendLine("echo \$! > '$oomGuardPid'")
// tun device node
appendLine("if [ ! -e /dev/net/tun ]; then mkdir -p /dev/net; mknod /dev/net/tun c 10 200; chmod 666 /dev/net/tun; fi")
// hev-socks5-tunnel config: it creates the tun ($TUN) itself and forwards it to the
// in-process core's SOCKS inbound on loopback. MTU comes from the existing VPN MTU
// setting. No fwmark on hev's sockets: its only upstream connection is to 127.0.0.1
// (loopback, already RETURNed by the 127.0.0.0/8 bypass) and the core's real outbound
// runs as the app uid (RETURNed by the uid-owner rule), so traffic can't loop.
appendLine("cat > '$cfgFile' <<'HEVCFG'")
append(buildHevConfig(socksUsername = socksUsername, socksPassword = socksPassword, socksPort = port, ipv6 = ipv6))
appendLine("HEVCFG")
appendLine("nohup \"\$BIN\" '$cfgFile' >'$logFile' 2>&1 &")
// The HEV config is written separately by the app process. Never place credentials
// in this root shell script, even when YAML quoting would otherwise be valid.
appendLine("nohup \"\$BIN\" '$cfgPath' >'$logFile' 2>&1 &")
appendLine("T2S_PID=\$!")
appendLine("echo \$T2S_PID > '$pidFile'")
appendLine("echo ${AppConfig.ROOT_OOM_SCORE} > /proc/\$T2S_PID/oom_score_adj 2>/dev/null || true")
@@ -212,13 +217,33 @@ object RootProxyManager {
appendLine(" address: '${AppConfig.LOOPBACK}'")
appendLine(" udp: 'udp'")
if (socksUsername != null && socksPassword != null) {
appendLine(" username: '$socksUsername'")
appendLine(" password: '$socksPassword'")
appendLine(" username: ${socksUsername.toSingleQuotedYamlScalar()}")
appendLine(" password: ${socksPassword.toSingleQuotedYamlScalar()}")
}
appendLine(" tcp-fastopen: true")
}
}
private fun writeHevConfig(file: File, config: String): Boolean {
val atomicFile = AtomicFile(file)
val output = try {
atomicFile.startWrite()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to open HEV config at ${file.absolutePath}", e)
return false
}
return try {
output.write(config.toByteArray(Charsets.UTF_8))
atomicFile.finishWrite(output)
true
} catch (e: Exception) {
atomicFile.failWrite(output)
LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to write HEV config at ${file.absolutePath}", e)
false
}
}
/**
* mangle OUTPUT marking chain (ipv4/ipv6). Mirrors VpnService's capture behavior:
* - all-apps (no per-app): mark EVERY remaining uid (incl uid 0 + all system uids), so
@@ -473,3 +498,5 @@ object RootProxyManager {
}
}
}
internal fun String.toSingleQuotedYamlScalar(): String = "'${replace("'", "''")}'"
@@ -4,7 +4,6 @@ import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Minimal root command runner backed by the `su` binary.
@@ -24,32 +23,29 @@ object RootShell {
val dir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
val file = File(dir, name).apply {
writeText(script)
setExecutable(true, false)
setReadable(false, false)
setReadable(true, true)
setWritable(false, false)
setWritable(true, true)
}
val safePath = file.absolutePath.replace("'", "'\\''")
return exec("sh '$safePath'")
// Android toybox timeout owns the root shell, so killing the su client cannot leave
// a timed-out setup shell installing rules after rollback has already started.
return exec("timeout -s TERM -k 2 25 sh '$safePath'")
}
private fun exec(command: String, timeoutSeconds: Long = 30): Result {
return try {
val process = ProcessBuilder("su", "-c", command)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
LogUtil.e(AppConfig.TAG, "RootShell: timed out: $command")
return Result(-1, output)
}
val result = Result(process.exitValue(), output)
val outcome = RootProcessRunner.run(listOf("su", "-c", command), timeoutSeconds * 1000)
val result = Result(outcome.code, outcome.output)
if (!result.success) {
LogUtil.w(AppConfig.TAG, "RootShell: '$command' exited ${result.code}: ${output.trim()}")
LogUtil.w(AppConfig.TAG, "RootShell: root command exited ${result.code}: ${result.output.trim()}")
}
result
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "RootShell: failed to run '$command'", e)
Result(-1, e.message ?: e.javaClass.simpleName)
if (e is InterruptedException) Thread.currentThread().interrupt()
LogUtil.e(AppConfig.TAG, "RootShell: root command failed", e)
Result(-1, e.javaClass.simpleName)
}
}
}
@@ -0,0 +1,92 @@
# Service code agent guide
These rules apply to the service and service-adjacent paths listed in the repository-root
`AGENTS.md`. Follow both guides; apply the precedence rule defined by the root guide.
## Process and lifecycle invariants
- The daemon process `:RunSoLibV2RayDaemon` is the only authority for whether the native
core is running. Do not use a UI-process singleton, cached Boolean, bound-service
assumption, or successful broadcast send as proof of daemon state.
- Route every app-initiated VPN, proxy-only, and root start through `LauncherManager`.
Route service commands and their results through `MessageHelper`. Put lifecycle state
shared by two or more run modes in `CoreServiceManager` or `ServiceControl`. Do not
load or query the native core from the UI process to decide start, stop, or restart.
- Keep Android-owned `VpnService` entry paths intact. `CoreVpnService` must accept a null
restart intent and the `VpnService.SERVICE_INTERFACE` action without requiring the
app-initiated `LauncherManager` path.
- A start path is idempotent only when a second equivalent command leaves exactly one
native core, tunnel, foreground notification update loop, worker set, and root rule
set. Every edit to start dispatch or `onStartCommand` must preserve that result.
- In each `onStartCommand`, enter foreground before reading configuration, opening a
file or socket, starting a coroutine that performs setup, invoking native code, or
executing a root command. `CoreVpnService`, `CoreProxyOnlyService`, and
`CoreRootService` must call `NotificationManager.ensureForeground()`;
`CoreTestService` and `SubscriptionUpdateService` must call
`NotificationHelper.startForeground()`.
- On setup failure, cancel the setup job, release every resource created by that attempt,
remove partial root or VPN state, and stop the failed service instance. Do not return a
sticky restart mode unless `CoreServiceManager.isRunning()` is true, the service owns
every tunnel or root resource required by its run mode, and the Android restart path
reconstructs configuration, native core, notification, and mode-owned resources
without UI-process state.
- Preserve these ownership boundaries: `CoreVpnService` owns the VPN interface and
socket protection; `CoreProxyOnlyService` owns local-proxy mode; `CoreRootService`
owns root routing. Code used by two or more of these services belongs in their common
lifecycle layer. Code used by one mode remains in that mode's service.
## Cleanup, concurrency, networking, and IPC
- Teardown must prevent new work before releasing dependencies. Mark the service as
stopping, reject or invalidate pending start/reload work, cancel and join setup jobs,
then release owned resources. Remove root routing before stopping its core listener.
Close each VPN descriptor and tun2socks resource once on setup failure, revoke, stop,
and destroy; make repeated teardown calls no-ops after the first close.
- Every new coroutine launched by a service must be a child of a job or scope stored by
that service or the shared lifecycle owner. Cancel that owner during stop and
`onDestroy`. Do not use `GlobalScope`, an anonymous standalone `CoroutineScope`, or a
job whose parent outlives the service and later recreates routes, notifications,
files, or native state.
- Do not add root commands, file I/O, network probes, or native calls directly to
`onCreate`, `onStartCommand`, `onRevoke`, or `onDestroy` on the main thread. Perform
the operation in the service-owned scope. If traffic-leak prevention requires the
callback to await completion, the call site must contain an explicit timeout and a
comment naming the leak-prevention ordering; an unbounded wait is prohibited.
- A handover belongs to the `NetworkMonitor` instance that scheduled it.
`NetworkMonitor.unregister()` must cancel pending handover work and prevent its
callback from entering after `unregister()` returns. Immediately before reload, the
handover handler must verify that its monitor instance is still the instance owned by
`CoreServiceManager` and that the core is running. Stop and destroy must unregister
the monitor before clearing it or stopping the core.
- Every app-internal broadcast intent must set its package to `AppConfig.ANG_PACKAGE`;
every app-internal service intent must use an explicit `ComponentName`. Do not add an
implicit broadcast or service intent. Each payload must implement the serialization
contract used by its receiver. When a caller must distinguish `handled by daemon`
from `daemon absent`, return an acknowledgement/result from the daemon. Broadcast
delivery, bind success, and command enqueue success are not acknowledgements.
## Native resources, logging, and validation
- `TProxyService` loads the JNI library `libhev-socks5-tunnel.so`; root mode executes
the separately packaged `libhevsockstun.so`. A native or packaging diff must inspect
the produced APK as a ZIP and confirm both HEV files and `libgojni.so` for every ABI
selected by `ABI_FILTERS` or, when that property is absent, every ABI in the app's
default `splits.abi` block.
- A recoverable start, stop, reload, handover, or cleanup failure log must include the
run mode, lifecycle phase, stable non-secret profile/group identifier when one exists,
failed operation, and exception. Apply the root guide's secret-redaction rule.
- Move deterministic lifecycle decisions into pure helpers and add a JVM regression test
for each changed decision. Apply this scenario mapping to service changes:
- Start dispatch, `onStartCommand`, or command deduplication: cold start and two
equivalent consecutive start commands.
- Foreground setup or setup-error handling: successful setup and failure after at
least one resource has been acquired.
- Stop or teardown: normal stop, repeated stop, and stop while setup is in flight.
- Restart or network handover: successful restart/handover and a stop racing the
pending restart/handover.
- Shared lifecycle code: run the mapped scenarios in VPN, proxy-only, and root modes.
Mode-owned code: run them in the owning mode.
- Every service lifecycle scenario above requires an emulator or physical device; a JVM
test or assembled APK is not a substitute. Record each scenario that did not run under
`Not run`, with the device requirement or blocker. Do not claim the service change is
runtime-verified when any mapped scenario is unrun.
@@ -0,0 +1 @@
@AGENTS.md
@@ -10,6 +10,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
@@ -19,7 +20,7 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.util.LogUtil
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class CoreTestService : Service() {
@@ -28,7 +29,7 @@ class CoreTestService : Service() {
}
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val activeWorkers = ConcurrentHashMap<RealPingWorkerService, String>()
private val cancelAction by lazy {
val intent = Intent(this, CoreTestService::class.java).putExtra(
"content",
@@ -69,10 +70,7 @@ class CoreTestService : Service() {
*/
override fun onDestroy() {
LogUtil.i(AppConfig.TAG, "CoreTestService is being destroyed, cancelling ${activeWorkers.size} active workers")
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
cancelWorkers()
NotificationHelper.stopForeground(this)
super.onDestroy()
}
@@ -99,7 +97,9 @@ class CoreTestService : Service() {
}
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(message, startId)
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(
message, startId, intent.getStringExtra(MessageHelper.EXTRA_REQUEST_ID).orEmpty()
)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> handleMeasureCancel()
else -> {
NotificationHelper.stopForeground(this); stopSelf(startId)
@@ -108,7 +108,7 @@ class CoreTestService : Service() {
return START_NOT_STICKY
}
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
private fun handleMeasureStart(message: TestServiceMessage, startId: Int, requestId: String) {
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
val guidsList = when {
@@ -123,17 +123,22 @@ class CoreTestService : Service() {
context = this,
guids = guidsList,
onlyTcp = message.onlyTcp,
onEvent = { event -> handleWorkerEvent(event, message) { activeWorkers.remove(worker) } }
onEvent = { event ->
if (activeWorkers.containsKey(worker)) {
handleWorkerEvent(event, message, requestId) { activeWorkers.remove(worker) }
}
}
)
activeWorkers.add(worker)
activeWorkers[worker] = requestId
worker.start()
} else {
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
NotificationHelper.stopForeground(this)
stopSelf(startId)
}
}
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, onWorkerDone: () -> Unit) {
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, requestId: String, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
@@ -142,12 +147,17 @@ class CoreTestService : Service() {
title = getString(R.string.app_name),
content = getString(R.string.connection_running_task_left, event.text)
)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text, requestId)
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
MessageHelper.sendMsg2UI(
this,
AppConfig.MSG_MEASURE_CONFIG_SUCCESS,
RealPingResult(event.guid, event.delayMillis),
requestId,
)
}
is RealPingEvent.Finish -> {
@@ -161,7 +171,7 @@ class CoreTestService : Service() {
}
}
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status, requestId)
onWorkerDone()
if (activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this)
@@ -172,12 +182,19 @@ class CoreTestService : Service() {
}
private fun handleMeasureCancel() {
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, "0")
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
cancelWorkers()
NotificationHelper.stopForeground(this)
stopSelf()
}
private fun cancelWorkers() {
// Reply for each cancelled request, never for a newer UI request that has not started here.
activeWorkers.entries.toList().forEach { (worker, requestId) ->
if (activeWorkers.remove(worker, requestId)) {
worker.cancel()
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
}
}
}
}
@@ -3,12 +3,12 @@ package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.IDialerService
import com.v2ray.ang.extension.delay
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import com.v2ray.ang.extension.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.Call
@@ -8,18 +8,21 @@ import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import com.v2ray.ang.contracts.IDialerService
import java.util.concurrent.ExecutionException
import java.util.concurrent.FutureTask
import java.util.concurrent.atomic.AtomicLong
class DialerWebviewService : IDialerService {
private var webView: WebView? = null
private val handler = Handler(Looper.getMainLooper())
private val lifecycleVersion = AtomicLong()
private val keepAliveInterval = 30_000L // 30 seconds
private val keepAliveRunnable = object : Runnable {
override fun run() {
webView?.let {
it.resumeTimers()
it.onResume()
}
val view = webView ?: return
view.resumeTimers()
view.onResume()
handler.postDelayed(this, keepAliveInterval)
}
}
@@ -30,45 +33,88 @@ class DialerWebviewService : IDialerService {
* @param context Service context
*/
@SuppressLint("SetJavaScriptEnabled")
override fun start(context: Context, dialerAddr: String) {
if (webView != null) stop()
if (dialerAddr.isEmpty()) return
override fun start(context: Context, dialerAddr: String) = updateOnMainThread {
stopOnMainThread()
if (dialerAddr.isEmpty()) return@updateOnMainThread
val dialerUrl = "http://$dialerAddr/"
webView = WebView(context.applicationContext).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
// Allow JS to run even if not triggered by user
mediaPlaybackRequiresUserGesture = false
// Prevent aggressive caching issues
cacheMode = WebSettings.LOAD_DEFAULT
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
view?.onResume()
view?.resumeTimers()
val view = WebView(context.applicationContext)
webView = view
try {
view.apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
// Allow JS to run even if not triggered by user
mediaPlaybackRequiresUserGesture = false
// Prevent aggressive caching issues
cacheMode = WebSettings.LOAD_DEFAULT
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
if (view === webView) {
view?.onResume()
view?.resumeTimers()
}
}
}
loadUrl(dialerUrl)
}
loadUrl(dialerUrl)
handler.post(keepAliveRunnable)
} catch (e: Exception) {
try {
stopOnMainThread()
} catch (cleanupError: Exception) {
e.addSuppressed(cleanupError)
}
throw e
}
handler.post(keepAliveRunnable)
}
override fun stop() {
override fun stop() = updateOnMainThread { stopOnMainThread() }
private fun stopOnMainThread() {
handler.removeCallbacks(keepAliveRunnable)
webView?.apply {
stopLoading()
pauseTimers()
// Important to call onPause to stop internal Chromium threads properly
onPause()
destroy()
}
val view = webView ?: return
webView = null
view.apply {
try {
stopLoading()
pauseTimers()
// Important to call onPause to stop internal Chromium threads properly
onPause()
} finally {
destroy()
}
}
}
private fun updateOnMainThread(action: () -> Unit) {
val version = lifecycleVersion.incrementAndGet()
val update = {
// A stop or replacement can overtake a worker's queued start on the main thread.
if (lifecycleVersion.get() == version) action()
}
if (Looper.myLooper() == handler.looper) {
update()
return
}
// WebView requires its creating looper on all Android versions. Keep the dialer's
// synchronous contract so reload waits for cleanup/setup and receives any failure.
val task = FutureTask(update)
check(handler.post(task)) { "WebView looper is shutting down" }
try {
task.get()
} catch (e: ExecutionException) {
throw e.cause ?: e
} catch (e: InterruptedException) {
task.cancel(false)
Thread.currentThread().interrupt()
throw e
}
}
}
@@ -0,0 +1 @@
@AGENTS.md
@@ -5,12 +5,12 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.v2ray.ang.AppConfig
import com.v2ray.ang.extension.delay
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import com.v2ray.ang.extension.delay
import kotlinx.coroutines.launch
/**
@@ -97,7 +97,7 @@ class TProxyService(
appendLine("misc:")
appendLine(" tcp-read-write-timeout: ${tcpTimeout * 1000}")
appendLine(" udp-read-write-timeout: ${udpTimeout * 1000}")
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) ?: "warn"}")
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)}")
}
}
@@ -0,0 +1,95 @@
# UI code agent guide
These rules apply to the UI paths listed in the repository-root `AGENTS.md`. Follow both
guides; apply the precedence rule defined by the root guide.
## Compose architecture and state
- Implement every new screen and component with the existing Compose and Material 3
stack. A new screen activity must extend `BaseComponentActivity` or
`HelperBaseComponentActivity` and place its screen, ViewModel, and action contract in
the package that owns its navigation entry. Do not add a fragment, ViewBinding layout,
XML screen layout, or RecyclerView adapter. Do not migrate existing legacy UI outside
task scope, and do not extend it or use it as the template for new UI.
- Keep durable screen state and business work in a ViewModel/repository and expose it as
immutable observable state. Collect flows with `collectAsStateWithLifecycle()`.
Compose-local state is only for transient presentation state; use `rememberSaveable`
when that state must survive activity recreation.
- Durable state includes loaded data, selection by domain ID, validation results,
progress, errors, and any value written to settings or a profile. Transient
presentation state is limited to visual expansion, animation, scroll/focus position,
and an uncommitted input value owned solely by the visible component. A composable
must not perform repository, MMKV, import/export, routing, or native-core work.
- Send user operations through the owning feature's action/event contract and
ViewModel. Main-screen operations must use `MainAction` and `MainViewModel`.
Composables must not mutate a repository, `MmkvManager`, or `SettingsManager`
directly.
- Use server GUIDs and group IDs for Compose keys, selection, saved state, and action
parameters. Do not persist or dispatch a visible index, adapter position, or paging
offset as identity. After filtering, sorting, subscription replacement, paging, or an
asynchronous result, resolve the item again by its domain ID.
- Run blocking file, network, package-manager, and bitmap-decoding operations on
`Dispatchers.IO`. Run CPU-bound parsing, sorting, filtering, and transformation on
`Dispatchers.Default`. Launch both from the owning ViewModel or lifecycle scope.
Publish results through a private `MutableStateFlow` exposed as `StateFlow`; do not
read or write Compose snapshot state on either background dispatcher.
## Interaction, accessibility, and layout
- Give every icon with a click, long-click, toggle, or custom accessibility action a
localized accessible name that states its action. Set `contentDescription = null` on
every icon with no user action. Do not expose a raw URL,
package ID, GUID, or duplicated descendant label when a row-level semantic node
supplies the name.
- A row with one activation behavior must expose one focusable semantic node, one
localized name, its current selected/on/off state, and one activation action. Merge or
clear descendant semantics so a label, icon, checkbox, and switch do not become
duplicate focus targets. Keep a descendant as a separate node only when it performs a
different user action; give that node its own name and role. TalkBack, touch, Enter,
Space, and D-pad center must invoke the same action for the same node.
- Key each focusable list item by its domain ID. Reordering or recomposition must keep
focus on the node with that ID if it still exists. If a selection change removes or
replaces the focused node, move focus exactly once to the final active item after the
state update; do not announce or focus an intermediate item.
- Use `stringResource` for text created inside a composable and localized
`Context.getString`/resources outside Compose. This rule covers visible labels,
semantic labels, state descriptions, validation messages, errors, dialogs, snackbars,
and toasts. Apply the root guide's locale and placeholder requirements to every key.
- Every form containing a text-editing control must use a vertically scrollable
container. Inside a `Scaffold`, apply its `innerPadding`, call
`consumeWindowInsets(innerPadding)`, and
apply `imePadding()` to that container. Outside a `Scaffold`, apply `imePadding()` and
consume the navigation-bar inset exactly once. With the IME visible, the user must be
able to focus, read, edit, and activate both the first and last form controls without
dismissing the IME.
- A shared component used by server screens must preserve edge-to-edge system-bar
insets, light and dark themes, dynamic color enabled and disabled, and both single-
and double-column layouts. A change to such a component requires rendering and
interaction checks in each of those configurations; dynamic-color checks require a
device or emulator that supports dynamic color.
## Validation mapping
Apply every row whose trigger matches the UI diff:
- ViewModel state, action mapping, validation, filtering, parsing, or selection logic:
add or update a JVM test that asserts the initial state and every success, invalid-
input, empty-input, and failure branch added or modified by the diff.
- `rememberSaveable`, `SavedStateHandle`, activity recreation, or restoration logic:
recreate the activity and verify the final state by domain ID, not list position.
- Loading or data presentation: exercise every state represented by the owning state
model among empty, loading, error, and populated; do not invent states absent from
that model.
- Click, toggle, selection, or navigation behavior: verify touch, keyboard focus and
activation, and D-pad focus and activation.
- Semantics, focus, accessible text, row merging, or icon meaning: inspect the semantics
tree and run TalkBack on an emulator or device. Record the spoken label, state, focus
order, and activation result.
- Form fields, scrolling, or insets: open the IME, traverse from the first control to the
last control, and activate the primary action with the IME still visible.
- A setting, profile, routing rule, or other persisted value: perform the action, close
and reopen the screen, and verify the value through the owning ViewModel/repository.
A screenshot verifies appearance only. It does not verify semantics, focus, activation,
state ownership, persistence, or recreation. Record every mapped check that did not run
under `Not run`; do not claim the corresponding behavior is verified.
@@ -1,9 +1,9 @@
package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import android.webkit.WebView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -46,12 +46,20 @@ class AboutActivity : BaseComponentActivity() {
@Composable
override fun ScreenContent() {
AboutScreen(onBackClick = { finish() })
AboutScreen(
onBackClick = { finish() },
onTranslatorsClick = {
startActivity(Intent(this, TranslatorsActivity::class.java))
}
)
}
}
@Composable
fun AboutScreen(onBackClick: () -> Unit) {
fun AboutScreen(
onBackClick: () -> Unit,
onTranslatorsClick: () -> Unit
) {
val context = LocalContext.current
var showOssDialog by remember { mutableStateOf(false) }
@@ -84,6 +92,11 @@ fun AboutScreen(onBackClick: () -> Unit) {
title = stringResource(R.string.title_oss_license),
onClick = { showOssDialog = true }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_translate_24dp),
title = stringResource(R.string.title_translators),
onClick = onTranslatorsClick
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_feedback_24dp),
title = stringResource(R.string.title_pref_feedback),
@@ -0,0 +1 @@
@AGENTS.md
@@ -0,0 +1 @@
@AGENTS.md
@@ -0,0 +1,155 @@
package com.v2ray.ang.ui
import android.os.Bundle
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
import com.v2ray.ang.dto.TranslatorsCredit
import com.v2ray.ang.dto.TranslatorsParser
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
import com.v2ray.ang.util.Utils
class TranslatorsActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
@Composable
override fun ScreenContent() {
TranslatorsScreen(onBackClick = { finish() })
}
}
@Composable
fun TranslatorsScreen(onBackClick: () -> Unit) {
val context = LocalContext.current
val credits = remember {
TranslatorsParser.parse(Utils.readTextFromAssets(context, "translators.json"))
}
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = stringResource(R.string.title_translators),
onBackClick = onBackClick
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(credits, key = { it.language }) { credit ->
TranslationCreditCard(credit)
}
item {
NavigationBarsSpacer()
}
}
}
}
@Composable
private fun TranslationCreditCard(credit: TranslatorsCredit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column {
Text(
text = credit.language,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
credit.contributors.forEachIndexed { index, contributor ->
if (index > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
ContributorRow(
displayName = contributor.displayName ?: contributor.name,
linkUrl = contributor.url
)
}
}
}
}
@Composable
private fun ContributorRow(displayName: String, linkUrl: String?) {
val context = LocalContext.current
val isLink = linkUrl != null
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (isLink) {
Modifier.clickable(role = Role.Button) {
Utils.openUri(context, linkUrl)
}
} else {
Modifier
}
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isLink) {
Icon(
painter = painterResource(R.drawable.ic_github_24dp),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
Text(
text = displayName,
style = MaterialTheme.typography.bodyLarge,
color = if (isLink) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.padding(start = if (isLink) 12.dp else 0.dp)
)
}
}
@@ -17,7 +17,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -40,8 +40,8 @@ import com.v2ray.ang.ui.base.HelperBaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
import com.v2ray.ang.ui.compose.InputDialog
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
import com.v2ray.ang.ui.compose.InputField
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
import com.v2ray.ang.ui.compose.SelectListDialog
import com.v2ray.ang.ui.compose.SettingsMenuItem
import com.v2ray.ang.util.LogUtil
@@ -185,17 +185,20 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
}
}
private fun performRestore(cacheDir: File, zipFile: File): Boolean {
val backupDir = cacheDir.absolutePath + "/${System.currentTimeMillis()}"
private suspend fun performRestore(cacheDir: File, zipFile: File): Boolean =
withContext(Dispatchers.IO) {
val backupDir = File(cacheDir, "restore_${System.nanoTime()}")
try {
if (!ZipUtil.unzipToFolder(zipFile, backupDir.absolutePath)) {
return@withContext false
}
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
return false
val count = MMKV.restoreAllFromDirectory(backupDir.absolutePath)
SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService()
count > 0
} finally {
backupDir.deleteRecursively()
}
}
val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService()
return count > 0
}
}
@@ -96,6 +96,13 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
return localizedContext.getString(resId, *formatArgs)
}
/**
* Get a localized quantity string from a plurals resource.
*/
fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any): String {
return localizedContext.resources.getQuantityString(resId, quantity, *formatArgs)
}
/**
* Send finish activity event.
*/
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui.checkupdate
import android.os.Bundle
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -32,7 +32,6 @@ class CheckUpdateViewModel(application: Application) : BaseViewModel(application
fun checkForUpdates() {
launchLoading {
toast(R.string.update_checking_for_update)
try {
val result = UpdateCheckerManager.checkForUpdate(_checkPreRelease.value)
if (result.hasUpdate) {
@@ -6,9 +6,9 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
@@ -55,8 +55,8 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.v2ray.ang.R
import com.v2ray.ang.util.AppIconFetcher
import sh.calvin.reorderable.ReorderableCollectionItemScope
@@ -37,6 +37,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
@@ -47,6 +48,7 @@ fun ConfirmDialog(
confirmText: String = stringResource(R.string.action_ok),
dismissText: String? = stringResource(R.string.action_cancel),
confirmIcon: @Composable (() -> Unit)? = null,
messageTextAlign: TextAlign = TextAlign.Start,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -61,7 +63,14 @@ fun ConfirmDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = title?.let { { Text(it) } },
text = { Text(message, style = MaterialTheme.typography.bodyMedium) },
text = {
Text(
text = message,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
textAlign = messageTextAlign
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(); onDismiss() },
@@ -91,9 +100,11 @@ fun DeleteConfirmDialog(
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
itemName: String? = null,
) {
ConfirmDialog(
message = message,
message = if (itemName.isNullOrBlank()) message else "$message\n\n$itemName",
messageTextAlign = TextAlign.Center,
confirmText = stringResource(R.string.action_delete),
confirmIcon = {
Icon(
@@ -38,6 +38,8 @@ fun FormTextField(
keyboardType: KeyboardType = KeyboardType.Text,
placeholder: String? = null,
maxLines: Int = 5,
isError: Boolean = false,
supportingText: String? = null,
) {
Box(
modifier = modifier
@@ -49,6 +51,8 @@ fun FormTextField(
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
isError = isError,
supportingText = supportingText?.let { { Text(it) } },
singleLine = false,
maxLines = maxLines,
enabled = enabled,
@@ -31,13 +31,12 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.v2ray.ang.extension.delay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import com.v2ray.ang.extension.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
enum class ToastType {
NORMAL, SUCCESS, ERROR, INFO
@@ -111,12 +111,12 @@ val colorFabInactiveDark = Color(0xFF646464) // Dark Gray
val dividerColorLight = Color(0xFFE0E0E0) // Light Gray
val dividerColorDark = Color(0xFF424242) // Dark Gray
// Toast Colors 70%
val toastNormalBgLight = Color(0xB3353A3E) // Dark Gray
val toastNormalBgDark = Color(0xB34A4F54) // Darker Gray
val toastSuccessBg = Color(0xB3388E3C) // Green
val toastErrorBg = Color(0xB3D50000) // Red
val toastInfoBg = Color(0xB33F51B5) // Indigo Blue
// Toast Colors 85%
val toastNormalBgLight = Color(0xD9353A3E) // Dark Gray
val toastNormalBgDark = Color(0xD94A4F54) // Darker Gray
val toastSuccessBg = Color(0xD9388E3C) // Green
val toastErrorBg = Color(0xD9D50000) // Red
val toastInfoBg = Color(0xD93F51B5) // Indigo Blue
val toastIconCircleBg = Color(0x33FFFFFF) // Semi-transparent White
val toastTextColor = Color.White // White
@@ -12,9 +12,10 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
@@ -131,7 +132,7 @@ fun LogcatScreen(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val logs by viewModel.filteredLogs.collectAsStateWithLifecycle()
val logs by viewModel.logEntries.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
var searchQuery by remember { mutableStateOf("") }
@@ -191,9 +192,10 @@ fun LogcatScreen(
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
viewModel.loadLogcat()
}) {
FloatingActionButton(
onClick = { viewModel.loadLogcat() },
modifier = Modifier.navigationBarsPadding()
) {
Icon(
painterResource(R.drawable.ic_restore_24dp),
contentDescription = stringResource(R.string.acc_refresh)
@@ -213,8 +215,8 @@ fun LogcatScreen(
.verticalScrollbar(listState),
contentPadding = NavigationBarsBottomPadding()
) {
itemsIndexed(items = logs, key = { index, _ -> index }) { _, log ->
LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) })
items(items = logs, key = { it.key }) { log ->
LogcatItem(log = log.text, onLongClick = { Utils.setClipboard(context, log.text) })
ItemDivider()
}
}
@@ -1,14 +1,20 @@
package com.v2ray.ang.ui.logcat
import android.app.Application
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.io.IOException
class LogcatViewModel(application: Application) : BaseViewModel(application) {
@@ -17,6 +23,10 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
private val _filteredLogs = MutableStateFlow<List<String>>(emptyList())
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
val logEntries: StateFlow<List<LogcatEntry>> = filteredLogs
.map(::createLogcatEntries)
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun loadLogcat() {
launchLoading {
@@ -73,4 +83,17 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
logsetsAll.filter { it.contains(currentFilter) }
}
}
}
}
data class LogcatEntry(val key: String, val text: String)
internal fun createLogcatEntries(logs: List<String>): List<LogcatEntry> {
val occurrences = HashMap<String, Int>()
// Identical lines are valid. Count oldest-first so filtering other lines or
// prepending newer entries does not change the keys of existing occurrences.
return logs.asReversed().map { line ->
val occurrence = occurrences.getOrDefault(line, 0)
occurrences[line] = occurrence + 1
LogcatEntry("$occurrence:$line", line)
}.asReversed()
}
@@ -76,7 +76,7 @@ class MainActivity : HelperBaseComponentActivity() {
ProfileEditorResult.EXTRA_RESTART_SERVICE, false
)
val selectedProfileSaved = action == ProfileEditorResult.ACTION_SAVED &&
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
mainViewModel.onAction(MainAction.RefreshGroups)
if (restartService || selectedProfileSaved) LauncherManager.restartService(this)
}
@@ -186,8 +186,7 @@ class MainActivity : HelperBaseComponentActivity() {
toast(R.string.title_file_chooser)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN &&
MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
) {
checkAndRequestPermission(PermissionType.ACCESS_LOCAL_NETWORK) {}
}
@@ -1,5 +1,7 @@
package com.v2ray.ang.ui.main
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -20,19 +22,24 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
import com.v2ray.ang.ui.compose.AppDivider
import com.v2ray.ang.ui.compose.colorFabActive
import com.v2ray.ang.ui.compose.colorFabInactiveDark
import com.v2ray.ang.ui.compose.colorFabInactiveLight
import kotlinx.coroutines.launch
@Composable
fun MainBottomBar(
@@ -41,6 +48,15 @@ fun MainBottomBar(
isDarkTheme: Boolean,
onAction: (MainAction) -> Unit
) {
val scope = rememberCoroutineScope()
val rotationAnim = remember { Animatable(0f) }
LaunchedEffect(isRunning) {
if (!isRunning) {
rotationAnim.snapTo(0f)
}
}
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
@@ -68,7 +84,17 @@ fun MainBottomBar(
}
}
FloatingActionButton(
onClick = { onAction(MainAction.ToggleService) },
onClick = {
if (!isRunning) {
scope.launch {
rotationAnim.animateTo(
targetValue = 720f,
animationSpec = tween(durationMillis = 3000)
)
}
}
onAction(MainAction.ToggleService)
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 24.dp)
@@ -85,7 +111,9 @@ fun MainBottomBar(
if (isRunning) R.string.acc_stop else R.string.acc_start
),
tint = Color.White,
modifier = Modifier.size(24.dp)
modifier = Modifier
.size(24.dp)
.graphicsLayer { rotationZ = rotationAnim.value }
)
}
}
@@ -66,5 +66,5 @@ sealed interface MainAction {
data class ImportBatchConfig(val configText: String) : MainAction
data class LocateHandled(val target: LocateTarget) : MainAction
data object LocateHandled : MainAction
}
@@ -57,9 +57,9 @@ interface MainDataSource : Closeable {
fun share2Clipboard(guid: String): Boolean
fun sendMsg2Service(msgId: Int, content: String)
fun sendMsg2TestService(msg: TestServiceMessage)
fun sendMsg2TestService(msg: TestServiceMessage, requestId: String? = null)
fun cancelAllPing()
fun testCurrentServerRealPing()
fun testCurrentServerRealPing(requestId: String)
fun syncSubscriptions()
fun initAssets()
@@ -1,10 +1,23 @@
package com.v2ray.ang.ui.main
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
data class ServerDeleteTarget(
val guid: String,
val profileName: String,
) {
companion object {
val Saver = listSaver<ServerDeleteTarget?, String>(
save = { target -> target?.let { listOf(it.guid, it.profileName) }.orEmpty() },
restore = { ServerDeleteTarget(it[0], it[1]) },
)
}
}
@Composable
fun MainDialogs(
showDelAllConfirm: Boolean,
@@ -16,7 +29,7 @@ fun MainDialogs(
showDelInvalidConfirm: Boolean,
onDismissDelInvalid: () -> Unit,
onConfirmDelInvalid: () -> Unit,
showRemoveConfirm: String?,
showRemoveConfirm: ServerDeleteTarget?,
onDismissRemove: () -> Unit,
onConfirmRemove: (String) -> Unit,
) {
@@ -41,11 +54,11 @@ fun MainDialogs(
onDismiss = onDismissDelInvalid
)
}
if (showRemoveConfirm != null) {
val guid = showRemoveConfirm
showRemoveConfirm?.let { target ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
onConfirm = { onConfirmRemove(guid) },
itemName = target.profileName,
onConfirm = { onConfirmRemove(target.guid) },
onDismiss = onDismissRemove
)
}
@@ -1,9 +1,11 @@
package com.v2ray.ang.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Text
@@ -11,10 +13,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.entities.ServersCache
@@ -28,32 +29,28 @@ fun GroupTabBar(
onTabClick: (Int) -> Unit,
modifier: Modifier = Modifier
) {
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex.coerceIn(0, groups.lastIndex),
modifier = modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
contentColor = MaterialTheme.colorScheme.onSurface,
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
ScrollableTabRow(
selectedTabIndex = selectedIndex,
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
edgePadding = 16.dp,
minTabWidth = 56.dp,
indicator = {
TabRowDefaults.PrimaryIndicator(
modifier = Modifier
.tabIndicatorOffset(
selectedTabIndex = selectedTabIndex.coerceIn(0, groups.lastIndex),
matchContentSize = true
)
.clip(RoundedCornerShape(3.dp)),
width = Dp.Unspecified,
indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
color = MaterialTheme.colorScheme.secondary
)
},
divider = {}
}
) {
groups.forEachIndexed { index, group ->
val serverFlow = remember(group.id, mainViewModel) {
mainViewModel.serversForGroup(group.id)
}
GroupTabItem(
group = group,
selected = index == selectedTabIndex,
serverFlowProvider = { mainViewModel.serversForGroup(group.id) },
selected = index == selectedIndex,
serverFlow = serverFlow,
onClick = { onTabClick(index) }
)
}
@@ -64,20 +61,23 @@ fun GroupTabBar(
private fun GroupTabItem(
group: GroupMapItem,
selected: Boolean,
serverFlowProvider: () -> StateFlow<List<ServersCache>>,
serverFlow: StateFlow<List<ServersCache>>,
onClick: () -> Unit
) {
val serverFlow = remember(group.id) { serverFlowProvider() }
val servers by serverFlow.collectAsStateWithLifecycle()
val text = if (group.id.isEmpty()) {
group.remarks
} else {
"${group.remarks} (${servers.size})"
}
Tab(
selected = selected,
onClick = onClick,
modifier = Modifier
.widthIn(min = 56.dp)
.heightIn(min = 48.dp),
text = {
val text = if (group.id.isEmpty()) {
group.remarks
} else {
"${group.remarks} (${servers.size})"
}
Text(
text = text,
maxLines = 1,
@@ -79,7 +79,7 @@ fun ShareMethodDialog(
more: Boolean,
onDismiss: () -> Unit,
onAction: (MainAction) -> Unit,
onRemove: (String) -> Unit,
onRemove: (String, String) -> Unit,
) {
val menuActions = serverMenuActions(
isComplexProfile = profile.configType.isComplexType(),
@@ -95,7 +95,7 @@ fun ShareMethodDialog(
ServerMenuAction.ShareClipboard -> onAction(MainAction.ShareClipboard(guid))
ServerMenuAction.ShareFullContent -> onAction(MainAction.ShareFullContent(guid))
ServerMenuAction.Edit -> onAction(MainAction.EditServer(guid, profile))
ServerMenuAction.Delete -> onRemove(guid)
ServerMenuAction.Delete -> onRemove(guid, profile.remarks)
}
},
onDismiss = onDismiss
@@ -9,6 +9,7 @@ import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.entities.ProfileItem
@@ -24,10 +25,9 @@ import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import java.util.concurrent.atomic.AtomicBoolean
class MainRepository(
@@ -39,40 +39,44 @@ class MainRepository(
private val closed = AtomicBoolean(false)
private val _mainServiceEvent = MutableSharedFlow<MainServiceEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// Probe results are finite and must remain lossless until the ViewModel coalesces them.
private val mainServiceEventChannel = Channel<MainServiceEvent>(Channel.UNLIMITED)
override val mainServiceEvent: SharedFlow<MainServiceEvent> = _mainServiceEvent.asSharedFlow()
override val mainServiceEvent: Flow<MainServiceEvent> = mainServiceEventChannel.receiveAsFlow()
private val serviceReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val safeIntent = intent ?: return
val requestId = safeIntent.getStringExtra(MessageHelper.EXTRA_REQUEST_ID).orEmpty()
val event = when (safeIntent.getIntExtra("key", 0)) {
AppConfig.MSG_STATE_RUNNING -> MainServiceEvent.StateRunning
AppConfig.MSG_STATE_NOT_RUNNING -> MainServiceEvent.StateNotRunning
AppConfig.MSG_STATE_START_SUCCESS -> MainServiceEvent.StateStartSuccess
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure(
safeIntent.getStringExtra("content")
)
AppConfig.MSG_STATE_STOP_SUCCESS -> MainServiceEvent.StateStopSuccess
AppConfig.MSG_MEASURE_DELAY_RESULT -> safeIntent
.serializable<ConnectionTestResult>("content")
?.let { MainServiceEvent.MeasureDelayResult(it) }
?.let { MainServiceEvent.MeasureDelayResult(it, requestId) }
AppConfig.MSG_MEASURE_DELAY_CANCEL -> MainServiceEvent.MeasureDelayCancelled(requestId)
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> MainServiceEvent.MeasureConfigSuccess
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> safeIntent
.serializable<RealPingResult>("content")
?.let { MainServiceEvent.MeasureConfigSuccess(it, requestId) }
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> MainServiceEvent.MeasureConfigNotify(
safeIntent.getStringExtra("content").orEmpty()
safeIntent.getStringExtra("content").orEmpty(), requestId
)
AppConfig.MSG_MEASURE_CONFIG_FINISH -> MainServiceEvent.MeasureConfigFinish(
safeIntent.getStringExtra("content")
requestId
)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> MainServiceEvent.MeasureConfigCancelled(requestId)
else -> null
}
event?.let { _mainServiceEvent.tryEmit(it) }
event?.let { mainServiceEventChannel.trySend(it) }
}
}
@@ -98,6 +102,7 @@ class MainRepository(
}.onFailure {
LogUtil.e(AppConfig.TAG, "Failed to unregister main service receiver", it)
}
mainServiceEventChannel.close()
}
override fun getSelectedSubscriptionId(): String =
@@ -205,8 +210,8 @@ class MainRepository(
override fun sendMsg2Service(msgId: Int, content: String) =
MessageHelper.sendMsg2Service(app, msgId, content)
override fun sendMsg2TestService(msg: TestServiceMessage) =
MessageHelper.sendMsg2TestService(app, msg)
override fun sendMsg2TestService(msg: TestServiceMessage, requestId: String?) =
MessageHelper.sendMsg2TestService(app, msg, requestId)
override fun cancelAllPing() {
sendMsg2TestService(
@@ -214,8 +219,10 @@ class MainRepository(
)
}
override fun testCurrentServerRealPing() {
sendMsg2Service(AppConfig.MSG_MEASURE_DELAY, "")
override fun testCurrentServerRealPing(requestId: String) {
MessageHelper.sendMsg2ServiceForResult(app, AppConfig.MSG_MEASURE_DELAY, requestId) { handled ->
if (!handled) mainServiceEventChannel.trySend(MainServiceEvent.MeasureDelayCancelled(requestId))
}
}
override fun syncSubscriptions() {
@@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
@@ -30,7 +31,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.ui.compose.LocalDarkTheme
import com.v2ray.ang.ui.compose.QRCodeDialog
import com.v2ray.ang.extension.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@@ -58,11 +58,17 @@ fun MainScreen(
var showDelAllConfirm by remember { mutableStateOf(false) }
var showDelDuplicateConfirm by remember { mutableStateOf(false) }
var showDelInvalidConfirm by remember { mutableStateOf(false) }
var showRemoveConfirm by remember { mutableStateOf<String?>(null) }
var showRemoveConfirm by rememberSaveable(stateSaver = ServerDeleteTarget.Saver) {
mutableStateOf<ServerDeleteTarget?>(null)
}
var shareTarget by remember { mutableStateOf<Triple<String, ProfileItem, Boolean>?>(null) }
val removeServer: (String) -> Unit = { guid ->
if (confirmRemove) showRemoveConfirm = guid else onAction(MainAction.RemoveServer(guid))
val removeServer: (String, String) -> Unit = { guid, profileName ->
if (confirmRemove) {
showRemoveConfirm = ServerDeleteTarget(guid, profileName)
} else {
onAction(MainAction.RemoveServer(guid))
}
}
val pagerState = rememberPagerState(
@@ -73,16 +79,12 @@ fun MainScreen(
val lazyListStates = remember { mutableStateMapOf<String, LazyListState>() }
val lazyGridStates = remember { mutableStateMapOf<String, LazyGridState>() }
var locateInProgress by remember { mutableStateOf(false) }
LaunchedEffect(groups) {
val validGroupIds = groups.map { it.id }.toSet()
lazyListStates.keys.retainAll(validGroupIds)
lazyGridStates.keys.retainAll(validGroupIds)
}
val latestDoubleColumnDisplay by rememberUpdatedState(doubleColumnDisplay)
LaunchedEffect(groups, uiState.selectedGroupId) {
if (groups.isEmpty()) return@LaunchedEffect
val selectedIndex = groups.indexOfFirst { it.id == uiState.selectedGroupId }
@@ -93,68 +95,18 @@ fun MainScreen(
}
val latestGroups by rememberUpdatedState(groups)
val latestLocateInProgress by rememberUpdatedState(locateInProgress)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }
.distinctUntilChanged()
.collect { page ->
val currentGroups = latestGroups
if (!latestLocateInProgress && page in currentGroups.indices) {
if (page in currentGroups.indices) {
onAction(MainAction.SelectGroup(currentGroups[page].id))
}
}
}
LaunchedEffect(uiState.locateTarget) {
val target = uiState.locateTarget ?: return@LaunchedEffect
if (target.groupIndex !in 0 until pagerState.pageCount) {
mainViewModel.onAction(MainAction.LocateHandled(target))
return@LaunchedEffect
}
locateInProgress = true
try {
if (pagerState.settledPage != target.groupIndex) {
pagerState.navigateToPageOptimized(
targetPage = target.groupIndex,
animateAdjacentPage = false
)
}
onAction(MainAction.SelectGroup(target.groupId))
repeat(10) {
val ready = if (latestDoubleColumnDisplay) {
lazyGridStates[target.groupId] != null
} else {
lazyListStates[target.groupId] != null
}
if (ready) return@repeat
delay(16)
}
if (latestDoubleColumnDisplay) {
lazyGridStates[target.groupId]?.let { gridState ->
gridState.scrollToItem(
index = target.itemPosition,
scrollOffset = -gridState.layoutInfo.viewportSize.height / 3
)
}
} else {
lazyListStates[target.groupId]?.let { listState ->
listState.scrollToItem(
index = target.itemPosition,
scrollOffset = -listState.layoutInfo.viewportSize.height / 3
)
}
}
} finally {
delay(32)
locateInProgress = false
mainViewModel.onAction(MainAction.LocateHandled(target))
}
}
MainDialogs(
showDelAllConfirm = showDelAllConfirm,
onDismissDelAll = { showDelAllConfirm = false },
@@ -279,8 +231,8 @@ fun MainScreen(
groupId = group.id,
mainViewModel = mainViewModel,
selectedGuid = selectedGuid,
locateTarget = uiState.locateTarget,
doubleColumnDisplay = doubleColumnDisplay,
confirmRemove = confirmRemove,
searchQuery = searchQuery,
lazyListStates = lazyListStates,
lazyGridStates = lazyGridStates,
@@ -30,6 +30,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -37,8 +38,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextOverflow
@@ -46,12 +47,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R
import com.v2ray.ang.dto.LocateTarget
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.ui.compose.ItemDivider
import com.v2ray.ang.ui.compose.ReorderableGridItem
import com.v2ray.ang.ui.compose.ReorderableListItem
@@ -69,8 +66,8 @@ fun GroupPagerPage(
groupId: String,
mainViewModel: MainViewModel,
selectedGuid: String?,
locateTarget: LocateTarget?,
doubleColumnDisplay: Boolean,
confirmRemove: Boolean,
searchQuery: String,
lazyListStates: MutableMap<String, LazyListState>,
lazyGridStates: MutableMap<String, LazyGridState>,
@@ -78,50 +75,67 @@ fun GroupPagerPage(
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit,
onRemoveServer: (String, String) -> Unit,
contentPadding: PaddingValues
) {
val serverFlow = remember(groupId) {
mainViewModel.serversForGroup(groupId)
val groupStateFlow = remember(groupId) {
mainViewModel.serverGroupState(groupId)
}
val servers by serverFlow.collectAsStateWithLifecycle()
val groupState by groupStateFlow.collectAsStateWithLifecycle()
val canReorder = groupId.isNotEmpty() && searchQuery.isEmpty()
val actions = remember(
onSelectServer,
onEditServer,
onShareServer,
onMoreServer,
onRemoveServer,
) {
ServerRowActions(
select = onSelectServer,
edit = onEditServer,
share = onShareServer,
more = onMoreServer,
remove = onRemoveServer,
)
}
ServerListPage(
servers = servers,
rows = groupState.rows,
selectedGuid = selectedGuid,
locateTarget = locateTarget?.takeIf { it.groupId == groupId },
canReorder = canReorder,
doubleColumnDisplay = doubleColumnDisplay,
subscriptionId = groupId,
confirmRemove = confirmRemove,
groupId = groupId,
lazyListStates = lazyListStates,
lazyGridStates = lazyGridStates,
onSelectServer = onSelectServer,
onEditServer = onEditServer,
onShareServer = onShareServer,
onMoreServer = onMoreServer,
onRemoveServer = onRemoveServer,
onMoveServer = { fromIndex, toIndex -> mainViewModel.moveServer(groupId, fromIndex, toIndex) },
actions = actions,
onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) },
onMoveServer = { fromIndex, toIndex ->
mainViewModel.moveServer(groupId, fromIndex, toIndex)
},
contentPadding = contentPadding
)
}
private class ServerRowActions(
val select: (String) -> Unit,
val edit: (String, ProfileItem) -> Unit,
val share: (String, ProfileItem) -> Unit,
val more: (String, ProfileItem) -> Unit,
val remove: (String, String) -> Unit,
)
@Composable
private fun ServerListPage(
servers: List<ServersCache>,
rows: List<ServerRowUiModel>,
selectedGuid: String?,
locateTarget: LocateTarget?,
canReorder: Boolean,
doubleColumnDisplay: Boolean,
subscriptionId: String,
confirmRemove: Boolean,
groupId: String,
lazyListStates: MutableMap<String, LazyListState>,
lazyGridStates: MutableMap<String, LazyGridState>,
onSelectServer: (String) -> Unit,
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit,
actions: ServerRowActions,
onLocateHandled: () -> Unit,
onMoveServer: (Int, Int) -> Unit,
contentPadding: PaddingValues
) {
@@ -135,6 +149,8 @@ private fun ServerListPage(
}
} else null
LocateTargetEffect(locateTarget, rows, gridState, onLocateHandled)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
state = gridState,
@@ -143,24 +159,19 @@ private fun ServerListPage(
.verticalScrollbar(gridState),
contentPadding = contentPadding
) {
itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache ->
itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row ->
val content: @Composable () -> Unit = {
ServerItemColumn(
serverCache = serverCache,
selectedGuid = selectedGuid,
subscriptionId = subscriptionId,
row = row,
isSelected = row.guid == selectedGuid,
doubleColumnDisplay = true,
onSelectServer = onSelectServer,
onEditServer = onEditServer,
onShareServer = onShareServer,
onMoreServer = onMoreServer,
onRemoveServer = onRemoveServer
actions = actions
)
}
if (canReorder && reorderableGridState != null) {
ReorderableItem(
reorderableGridState,
key = serverCache.guid
key = row.guid
) { isDragging ->
ReorderableGridItem(
scope = this,
@@ -182,6 +193,8 @@ private fun ServerListPage(
}
} else null
LocateTargetEffect(locateTarget, rows, listState, onLocateHandled)
LazyColumn(
state = listState,
modifier = Modifier
@@ -189,39 +202,29 @@ private fun ServerListPage(
.verticalScrollbar(listState),
contentPadding = contentPadding
) {
itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache ->
itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row ->
if (canReorder && reorderableState != null) {
ReorderableItem(
reorderableState,
key = serverCache.guid
key = row.guid
) { isDragging ->
ReorderableListItem(
scope = this,
isDragging = isDragging
) {
ServerItemRow(
serverCache = serverCache,
selectedGuid = selectedGuid,
subscriptionId = subscriptionId,
onSelectServer = onSelectServer,
onEditServer = onEditServer,
onShareServer = onShareServer,
onMoreServer = onMoreServer,
onRemoveServer = onRemoveServer
row = row,
isSelected = row.guid == selectedGuid,
actions = actions
)
}
ItemDivider()
}
} else {
ServerItemRow(
serverCache = serverCache,
selectedGuid = selectedGuid,
subscriptionId = subscriptionId,
onSelectServer = onSelectServer,
onEditServer = onEditServer,
onShareServer = onShareServer,
onMoreServer = onMoreServer,
onRemoveServer = onRemoveServer
row = row,
isSelected = row.guid == selectedGuid,
actions = actions
)
ItemDivider()
}
@@ -231,95 +234,80 @@ private fun ServerListPage(
}
@Composable
private fun ServerItemRow(
serverCache: ServersCache,
selectedGuid: String?,
subscriptionId: String,
onSelectServer: (String) -> Unit,
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit
private fun LocateTargetEffect(
target: LocateTarget?,
rows: List<ServerRowUiModel>,
state: LazyListState,
onHandled: () -> Unit,
) {
val profile = serverCache.profile
val subRemarks = if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
?.toString() ?: ""
} else ""
if (target == null) return
LaunchedEffect(target, rows) {
val index = rows.indexOfFirst { it.guid == target.serverGuid }
if (index < 0) return@LaunchedEffect
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
onHandled()
}
}
@Composable
private fun LocateTargetEffect(
target: LocateTarget?,
rows: List<ServerRowUiModel>,
state: LazyGridState,
onHandled: () -> Unit,
) {
if (target == null) return
LaunchedEffect(target, rows) {
val index = rows.indexOfFirst { it.guid == target.serverGuid }
if (index < 0) return@LaunchedEffect
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
onHandled()
}
}
@Composable
private fun ServerItemRow(
row: ServerRowUiModel,
isSelected: Boolean,
actions: ServerRowActions
) {
ServerListItem(
remarks = profile.remarks,
statistics = profile.description.nullIfBlank()
?: AngConfigManager.generateDescription(profile),
typeDescription = getProtocolDescription(profile),
testDelayMillis = serverCache.testDelayMillis,
isSelected = serverCache.guid == selectedGuid,
subscriptionRemarks = subRemarks,
row = row,
isSelected = isSelected,
doubleColumnDisplay = false,
onClick = { onSelectServer(serverCache.guid) },
onShare = { onShareServer(serverCache.guid, profile) },
onEdit = { onEditServer(serverCache.guid, profile) },
onRemove = { onRemoveServer(serverCache.guid) },
onMore = { onMoreServer(serverCache.guid, profile) }
actions = actions
)
}
@Composable
private fun ServerItemColumn(
serverCache: ServersCache,
selectedGuid: String?,
subscriptionId: String,
row: ServerRowUiModel,
isSelected: Boolean,
doubleColumnDisplay: Boolean,
onSelectServer: (String) -> Unit,
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit
actions: ServerRowActions
) {
val profile = serverCache.profile
val subRemarks = if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()?.toString() ?: ""
} else ""
Column {
ServerListItem(
remarks = profile.remarks,
statistics = profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile),
typeDescription = getProtocolDescription(profile),
testDelayMillis = serverCache.testDelayMillis,
isSelected = serverCache.guid == selectedGuid,
subscriptionRemarks = subRemarks,
row = row,
isSelected = isSelected,
doubleColumnDisplay = doubleColumnDisplay,
onClick = { onSelectServer(serverCache.guid) },
onEdit = { onEditServer(serverCache.guid, profile) },
onShare = { onShareServer(serverCache.guid, profile) },
onRemove = { onRemoveServer(serverCache.guid) },
onMore = { onMoreServer(serverCache.guid, profile) }
actions = actions
)
ItemDivider()
}
}
@Composable
fun ServerListItem(
remarks: String,
statistics: String,
typeDescription: String,
testDelayMillis: Long,
private fun ServerListItem(
row: ServerRowUiModel,
isSelected: Boolean,
subscriptionRemarks: String,
doubleColumnDisplay: Boolean,
onClick: () -> Unit,
onEdit: () -> Unit,
onShare: () -> Unit,
onRemove: () -> Unit,
onMore: () -> Unit,
modifier: Modifier = Modifier,
dragModifier: Modifier = Modifier
actions: ServerRowActions
) {
val testResult = if (testDelayMillis == 0L) {
val testResult = if (row.testDelayMillis == 0L) {
""
} else {
stringResource(R.string.server_test_delay_value, testDelayMillis)
stringResource(R.string.server_test_delay_value, row.testDelayMillis)
}
val selectedStateDescription = if (isSelected) {
stringResource(R.string.acc_selected_server)
@@ -327,7 +315,7 @@ fun ServerListItem(
null
}
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.semantics {
@@ -335,8 +323,7 @@ fun ServerListItem(
stateDescription = selectedStateDescription
}
}
.clickable(onClick = onClick)
.then(dragModifier)
.clickable { actions.select(row.guid) }
) {
Box(
Modifier
@@ -363,9 +350,9 @@ fun ServerListItem(
.padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(row.remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
if (doubleColumnDisplay) {
IconButton(onClick = onMore, Modifier.size(36.dp)) {
IconButton(onClick = { actions.more(row.guid, row.profile) }, Modifier.size(36.dp)) {
Icon(
painterResource(R.drawable.ic_more_vert_24dp),
stringResource(R.string.acc_more),
@@ -373,21 +360,21 @@ fun ServerListItem(
)
}
} else {
IconButton(onClick = onShare, Modifier.size(36.dp)) {
IconButton(onClick = { actions.share(row.guid, row.profile) }, Modifier.size(36.dp)) {
Icon(
painterResource(R.drawable.ic_share_24dp),
stringResource(R.string.title_configuration_share),
Modifier.size(24.dp)
)
}
IconButton(onClick = onEdit, Modifier.size(36.dp)) {
IconButton(onClick = { actions.edit(row.guid, row.profile) }, Modifier.size(36.dp)) {
Icon(
painterResource(R.drawable.ic_edit_24dp),
stringResource(R.string.acc_edit),
Modifier.size(24.dp)
)
}
IconButton(onClick = onRemove, Modifier.size(36.dp)) {
IconButton(onClick = { actions.remove(row.guid, row.remarks) }, Modifier.size(36.dp)) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
stringResource(R.string.acc_delete),
@@ -398,45 +385,34 @@ fun ServerListItem(
}
Spacer(modifier = Modifier.height(6.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (subscriptionRemarks.isNotBlank()) {
if (row.subscriptionBadge.isNotBlank()) {
Box(
Modifier
.size(24.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), Alignment.Center
) {
Text(subscriptionRemarks.take(1).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
Text(row.subscriptionBadge.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
}
}
Text(statistics, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
row.statistics,
Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(6.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(row.typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (row.testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
private fun getProtocolDescription(profile: ProfileItem): String {
if (profile.configType.isComplexType()) return profile.configType.name
val parts = mutableListOf(profile.configType.name)
profile.network?.let { net ->
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) parts.add(net)
}
profile.security?.let { sec ->
if (sec.isNotBlank()) {
if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) {
parts.add("$sec insecure")
} else {
parts.add(sec)
}
}
}
return parts.joinToString(" / ")
}
internal suspend fun PagerState.navigateToPageOptimized(
targetPage: Int,
animateAdjacentPage: Boolean = true
@@ -0,0 +1,61 @@
package com.v2ray.ang.ui.main
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.AngConfigManager
internal data class ServerRowUiModel(
val guid: String,
val profile: ProfileItem,
val remarks: String,
val statistics: String,
val typeDescription: String,
val testDelayMillis: Long,
val subscriptionBadge: String,
)
internal data class ServerGroupUiState(
val servers: List<ServersCache> = emptyList(),
val rows: List<ServerRowUiModel> = emptyList(),
)
internal fun buildServerRowUiModel(
server: ServersCache,
subscriptionRemarks: String,
): ServerRowUiModel {
val profile = server.profile
return ServerRowUiModel(
guid = server.guid,
profile = profile,
remarks = profile.remarks,
statistics = profile.description.nullIfBlank()
?: AngConfigManager.generateDescription(profile),
typeDescription = serverProtocolDescription(profile),
testDelayMillis = server.testDelayMillis,
subscriptionBadge = subscriptionRemarks.firstOrNull()?.toString().orEmpty(),
)
}
private fun serverProtocolDescription(profile: ProfileItem): String {
if (profile.configType.isComplexType()) return profile.configType.name
val parts = mutableListOf(profile.configType.name)
profile.network?.let { network ->
if (network.isNotBlank() && !network.equals("tcp", ignoreCase = true)) {
parts.add(network)
}
}
profile.security?.let { security ->
if (security.isNotBlank()) {
parts.add(
if (profile.insecure == true && security.equals("tls", ignoreCase = true)) {
"$security insecure"
} else {
security
}
)
}
}
return parts.joinToString(" / ")
}
@@ -1,15 +1,18 @@
package com.v2ray.ang.ui.main
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.RealPingResult
sealed class MainServiceEvent {
data object StateRunning : MainServiceEvent()
data object StateNotRunning : MainServiceEvent()
data object StateStartSuccess : MainServiceEvent()
data object StateStartFailure : MainServiceEvent()
data class StateStartFailure(val message: String? = null) : MainServiceEvent()
data object StateStopSuccess : MainServiceEvent()
data class MeasureDelayResult(val result: ConnectionTestResult) : MainServiceEvent()
data object MeasureConfigSuccess : MainServiceEvent()
data class MeasureConfigNotify(val progress: String) : MainServiceEvent()
data class MeasureConfigFinish(val finishedCount: String?) : MainServiceEvent()
data class MeasureDelayResult(val result: ConnectionTestResult, val requestId: String) : MainServiceEvent()
data class MeasureDelayCancelled(val requestId: String) : MainServiceEvent()
data class MeasureConfigSuccess(val result: RealPingResult, val requestId: String) : MainServiceEvent()
data class MeasureConfigNotify(val progress: String, val requestId: String) : MainServiceEvent()
data class MeasureConfigFinish(val requestId: String) : MainServiceEvent()
data class MeasureConfigCancelled(val requestId: String) : MainServiceEvent()
}
@@ -0,0 +1,37 @@
package com.v2ray.ang.ui.main
import java.util.UUID
/** Main-thread request ownership; a late reply must not complete a newer test. */
internal class MainTestRequests {
data class Bulk(val id: String, val groupId: String)
private var current: String? = null
var bulk: Bulk? = null
private set
val isTesting: Boolean get() = current != null || bulk != null
fun beginCurrent(): String = UUID.randomUUID().toString().also { current = it }
fun completeCurrent(id: String): Boolean {
if (id != current) return false
current = null
return true
}
fun invalidateCurrent() {
current = null
}
fun beginBulk(groupId: String): Bulk = Bulk(UUID.randomUUID().toString(), groupId).also { bulk = it }
fun completeBulk(id: String): Bulk? {
val request = bulk?.takeIf { it.id == id } ?: return null
bulk = null
return request
}
fun cancelBulk() {
bulk = null
}
}
@@ -9,10 +9,12 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.LocateTarget
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.extension.delay
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.matchesPattern
import com.v2ray.ang.extension.moveItem
@@ -23,12 +25,15 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.currentCoroutineContext
import com.v2ray.ang.extension.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -37,6 +42,30 @@ import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.PatternSyntaxException
private fun applyTestDelayResults(
servers: List<ServersCache>,
updates: Map<String, Long>,
): List<ServersCache> = servers.map { server ->
val delayMillis = updates[server.guid]
if (delayMillis == null || delayMillis == server.testDelayMillis) {
server
} else {
server.copy(testDelayMillis = delayMillis)
}
}
private fun applyTestDelayResultsToRows(
rows: List<ServerRowUiModel>,
updates: Map<String, Long>,
): List<ServerRowUiModel> = rows.map { row ->
val delayMillis = updates[row.guid]
if (delayMillis == null || delayMillis == row.testDelayMillis) {
row
} else {
row.copy(testDelayMillis = delayMillis)
}
}
class MainViewModel(
application: Application,
private val dataSource: MainDataSource
@@ -65,7 +94,8 @@ class MainViewModel(
// ---------- Groups & cache ----------
private val cacheMutex = Mutex()
private val groupDataCache = mutableMapOf<String, List<ServersCache>>()
private val groupPageFlows = ConcurrentHashMap<String, MutableStateFlow<List<ServersCache>>>()
private val groupUiFlows = ConcurrentHashMap<String, MutableStateFlow<ServerGroupUiState>>()
private val groupServerFlows = ConcurrentHashMap<String, StateFlow<List<ServersCache>>>()
private val groupLoadMutexes = ConcurrentHashMap<String, Mutex>()
private val serverOrderPersistenceJobs = mutableMapOf<String, Job>()
@@ -73,9 +103,11 @@ class MainViewModel(
private var preloadJob: Job? = null
private var selectedGroupLoadJob: Job? = null
private var reloadJob: Job? = null
private var testResultFlushJob: Job? = null
private val pendingTestResults = linkedMapOf<String, Long>()
@Volatile
private var testingGroupId: String? = null
private val testRequests = MainTestRequests()
private var bulkTestJob: Job? = null
private val initialPageReady = CompletableDeferred<Unit>()
@@ -102,31 +134,87 @@ class MainViewModel(
updateRunningState(true)
}
MainServiceEvent.StateStartFailure -> {
toastError(R.string.toast_services_failure)
is MainServiceEvent.StateStartFailure -> {
if (!event.message.isNullOrBlank()) {
toastError(event.message)
} else {
toastError(R.string.toast_services_failure)
}
updateRunningState(false)
}
MainServiceEvent.StateStopSuccess -> updateRunningState(false)
is MainServiceEvent.MeasureDelayResult -> {
_uiState.update { it.copy(status = MainStatus.ConnectionTest(event.result)) }
if (!uiState.value.isRunning || !testRequests.completeCurrent(event.requestId)) return
_uiState.update { it.copy(isTesting = testRequests.isTesting, status = MainStatus.ConnectionTest(event.result)) }
}
MainServiceEvent.MeasureConfigSuccess -> {
viewModelScope.launch(ioDispatcher) {
val gid = testingGroupId ?: uiState.value.selectedGroupId
cacheMutex.withLock { groupDataCache.remove(gid) }
updateGroupUi(gid, loadGroup(gid, forceRefresh = true))
}
is MainServiceEvent.MeasureConfigSuccess -> {
val request = testRequests.bulk?.takeIf { it.id == event.requestId } ?: return
queueTestResult(event.result, request)
}
is MainServiceEvent.MeasureConfigNotify -> {
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
if (event.requestId == testRequests.bulk?.id) {
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
}
}
is MainServiceEvent.MeasureConfigFinish -> {
onTestsFinished()
val request = testRequests.bulk?.takeIf { it.id == event.requestId } ?: return
val scheduledFlush = testResultFlushJob
testResultFlushJob = viewModelScope.launch {
scheduledFlush?.cancelAndJoin()
if (testRequests.bulk?.id != request.id) return@launch
flushPendingTestResults(request)
onTestsFinished(request.id)
}
}
is MainServiceEvent.MeasureDelayCancelled -> {
if (testRequests.completeCurrent(event.requestId)) resetTestStatus()
}
is MainServiceEvent.MeasureConfigCancelled -> {
if (testRequests.completeBulk(event.requestId) != null) {
cancelPendingTestResults()
resetTestStatus()
}
}
}
}
private fun queueTestResult(result: RealPingResult, request: MainTestRequests.Bulk) {
pendingTestResults[result.guid] = result.delayMillis
if (testResultFlushJob?.isActive == true) return
testResultFlushJob = viewModelScope.launch {
while (pendingTestResults.isNotEmpty()) {
delay(TEST_RESULT_FLUSH_INTERVAL_MS)
flushPendingTestResults(request)
}
}
}
private suspend fun flushPendingTestResults(request: MainTestRequests.Bulk) {
if (pendingTestResults.isEmpty()) return
if (testRequests.bulk?.id != request.id) return
val updates = cacheMutex.withLock {
val drained = pendingTestResults.toMap()
pendingTestResults.clear()
groupDataCache[request.groupId]?.let { cached ->
groupDataCache[request.groupId] = applyTestDelayResults(cached, drained)
}
drained
}
if (updates.isEmpty()) return
if (testRequests.bulk?.id != request.id) return
mutableServerGroupState(request.groupId).update { current ->
current.copy(
servers = applyTestDelayResults(current.servers, updates),
rows = applyTestDelayResultsToRows(current.rows, updates),
)
}
}
@@ -163,14 +251,25 @@ class MainViewModel(
// ---------- Public state accessors ----------
fun serversForGroup(groupId: String): StateFlow<List<ServersCache>> =
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) }
.asStateFlow()
groupServerFlows.computeIfAbsent(groupId) {
val groupState = mutableServerGroupState(groupId)
groupState
.map { it.servers }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
initialValue = groupState.value.servers,
)
}
private fun mutableServersForGroup(groupId: String): MutableStateFlow<List<ServersCache>> =
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) }
internal fun serverGroupState(groupId: String): StateFlow<ServerGroupUiState> =
mutableServerGroupState(groupId).asStateFlow()
private fun mutableServerGroupState(groupId: String): MutableStateFlow<ServerGroupUiState> =
groupUiFlows.computeIfAbsent(groupId) { MutableStateFlow(ServerGroupUiState()) }
private fun currentServers(): List<ServersCache> =
mutableServersForGroup(uiState.value.selectedGroupId).value
mutableServerGroupState(uiState.value.selectedGroupId).value.servers
// ---------- Action handler ----------
fun onAction(action: MainAction) {
@@ -191,7 +290,7 @@ class MainViewModel(
is MainAction.RemoveServer -> removeServerAndRefresh(action.guid)
is MainAction.Search -> filterConfig(action.query)
is MainAction.ImportBatchConfig -> importBatchConfig(action.configText)
is MainAction.LocateHandled -> consumeLocateTarget(action.target)
MainAction.LocateHandled -> consumeLocateTarget()
is MainAction.ShareQRCode -> {
val bitmap = dataSource.share2QRCode(action.guid)
_uiState.update { it.copy(shareQRCodeBitmap = bitmap) }
@@ -289,7 +388,31 @@ class MainViewModel(
}
private fun updateGroupUi(groupId: String, servers: List<ServersCache>) {
mutableServersForGroup(groupId).value = applyKeywordFilter(servers)
val filteredServers = applyKeywordFilter(servers)
mutableServerGroupState(groupId).value = ServerGroupUiState(
servers = filteredServers,
rows = buildServerRows(groupId, filteredServers)
)
}
private fun buildServerRows(groupId: String, servers: List<ServersCache>): List<ServerRowUiModel> {
val subscriptionRemarks = if (groupId.isEmpty()) {
servers.asSequence()
.map { it.profile.subscriptionId }
.filter { it.isNotEmpty() }
.distinct()
.associateWith { subscriptionId ->
dataSource.getSubscriptionItem(subscriptionId)?.remarks.orEmpty()
}
} else {
emptyMap()
}
return servers.map { server ->
buildServerRowUiModel(
server = server,
subscriptionRemarks = subscriptionRemarks[server.profile.subscriptionId].orEmpty()
)
}
}
fun getSubscriptions(): List<SubscriptionCache> = dataSource.getSubscriptions()
@@ -334,17 +457,18 @@ class MainViewModel(
}
val selectedGroup = resolveSelectedGroup(groups)
val validIds = groups.mapTo(HashSet()) { it.id }
groupPageFlows.keys.removeAll { it !in validIds }
groupUiFlows.keys.removeAll { it !in validIds }
groupServerFlows.keys.removeAll { it !in validIds }
groupLoadMutexes.keys.removeAll { it !in validIds }
_uiState.update {
it.copy(
groups = groups,
selectedGroupId = selectedGroup,
selectedGuid = dataSource.getSelectServer()
selectedGuid = dataSource.getSelectServer(),
)
}
groups.forEach { mutableServersForGroup(it.id) }
groups.forEach { mutableServerGroupState(it.id) }
if (groups.isEmpty()) {
cacheMutex.withLock { groupDataCache.clear() }
@@ -424,7 +548,13 @@ class MainViewModel(
toast(R.string.title_update_subscription_no_subscription)
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
toast(dataSource.getString(R.string.title_update_config_count, result.configCount))
toast(
getQuantityString(
R.plurals.title_update_config_count,
result.configCount,
result.configCount,
)
)
else ->
toast(dataSource.getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
@@ -582,7 +712,7 @@ class MainViewModel(
fun subscriptionIdChanged(id: String) {
if (_uiState.value.groups.none { it.id == id }) return
mutableServersForGroup(id)
mutableServerGroupState(id)
if (uiState.value.selectedGroupId != id) {
dataSource.setSelectedSubscriptionId(id)
_uiState.update { it.copy(selectedGroupId = id) }
@@ -660,10 +790,13 @@ class MainViewModel(
}
fun moveServer(groupId: String, fromPosition: Int, toPosition: Int) {
val servers = mutableServersForGroup(groupId).value.toMutableList()
val groupState = mutableServerGroupState(groupId).value
val servers = groupState.servers.toMutableList()
if (!servers.moveItem(fromPosition, toPosition)) return
val rows = groupState.rows.toMutableList()
rows.moveItem(fromPosition, toPosition)
val guids = servers.map { it.guid }
mutableServersForGroup(groupId).value = servers
mutableServerGroupState(groupId).value = ServerGroupUiState(servers, rows)
// A drag emits several moves; serialize writes so an older order cannot overwrite a newer one.
val previousPersistenceJob = serverOrderPersistenceJobs[groupId]
serverOrderPersistenceJobs[groupId] = viewModelScope.launch(ioDispatcher) {
@@ -675,67 +808,93 @@ class MainViewModel(
// ---------- Testing ----------
fun cancelAllPing() {
bulkTestJob?.cancel()
bulkTestJob = null
testRequests.cancelBulk()
testRequests.invalidateCurrent()
cancelPendingTestResults()
resetTestStatus()
dataSource.cancelAllPing()
testingGroupId = null
}
private fun resetTestStatus() {
_uiState.update {
it.copy(
isTesting = false,
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
isTesting = testRequests.isTesting,
status = if (testRequests.isTesting) MainStatus.Testing
else if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
)
}
}
fun testAllRealPing(onlyTcp: Boolean = false) {
dataSource.cancelAllPing()
cancelAllPing()
val groupId = uiState.value.selectedGroupId
val servers = currentServers()
if (servers.isEmpty()) {
_uiState.update { it.copy(isTesting = false) }
return
}
val serverGuids = servers.map { it.guid }
mutableServersForGroup(groupId).update { current ->
current.map { server ->
if (server.testDelayMillis == 0L) server
else server.copy(testDelayMillis = 0L)
}
mutableServerGroupState(groupId).update { current ->
current.copy(
servers = current.servers.map { server ->
if (server.testDelayMillis == 0L) server
else server.copy(testDelayMillis = 0L)
},
rows = current.rows.map { row ->
if (row.testDelayMillis == 0L) row
else row.copy(testDelayMillis = 0L)
}
)
}
testingGroupId = groupId
val request = testRequests.beginBulk(groupId)
val message = TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = groupId,
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
onlyTcp = onlyTcp
)
_uiState.update {
it.copy(
isTesting = true,
status = MainStatus.Testing
)
}
viewModelScope.launch(ioDispatcher) {
dataSource.clearAllTestDelayResults(serverGuids)
cacheMutex.withLock { groupDataCache.remove(groupId) }
dataSource.sendMsg2TestService(
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = groupId,
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
onlyTcp = onlyTcp
)
)
bulkTestJob = viewModelScope.launch {
withContext(ioDispatcher) {
dataSource.clearAllTestDelayResults(serverGuids)
val resetGuids = serverGuids.toHashSet()
cacheMutex.withLock {
groupDataCache[groupId]?.let { cached ->
groupDataCache[groupId] = cached.map { server ->
if (server.guid !in resetGuids || server.testDelayMillis == 0L) server
else server.copy(testDelayMillis = 0L)
}
}
}
}
dataSource.sendMsg2TestService(message, request.id)
}
}
fun testCurrentServerRealPing() {
_uiState.update { it.copy(status = MainStatus.Testing) }
dataSource.testCurrentServerRealPing()
private fun cancelPendingTestResults() {
testResultFlushJob?.cancel()
testResultFlushJob = null
pendingTestResults.clear()
}
private fun onTestsFinished() {
fun testCurrentServerRealPing() {
if (!uiState.value.isRunning) return
val requestId = testRequests.beginCurrent()
_uiState.update { it.copy(isTesting = true, status = MainStatus.Testing) }
dataSource.testCurrentServerRealPing(requestId)
}
private fun onTestsFinished(requestId: String) {
if (testRequests.completeBulk(requestId) == null) return
resetTestStatus()
viewModelScope.launch(ioDispatcher) {
cacheMutex.withLock { groupDataCache.clear() }
testingGroupId = null
_uiState.update {
it.copy(
isTesting = false,
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
)
}
reloadAllGroups(_uiState.value.groups.map { it.id })
}
}
@@ -744,32 +903,31 @@ class MainViewModel(
val selected = dataSource.getSelectServer() ?: return
val profile = dataSource.decodeServerConfig(selected) ?: return
val groupId = profile.subscriptionId
val groupIndex =
_uiState.value.groups.indexOfFirst { it.id == groupId }.takeIf { it >= 0 } ?: return
if (_uiState.value.groups.none { it.id == groupId }) return
viewModelScope.launch(ioDispatcher) {
val position =
loadGroup(groupId).indexOfFirst { it.guid == selected }.takeIf { it >= 0 }
?: return@launch
updateGroupUi(groupId, loadGroup(groupId))
if (_uiState.value.selectedGroupId != groupId) {
dataSource.setSelectedSubscriptionId(groupId)
}
val target = LocateTarget(groupId, selected)
_uiState.update {
it.copy(locateTarget = LocateTarget(groupId, groupIndex, position))
it.copy(selectedGroupId = groupId, locateTarget = target)
}
}
}
fun getPosition(guid: String): Int = currentServers().indexOfFirst { it.guid == guid }
private fun consumeLocateTarget(target: LocateTarget) {
_uiState.update { state ->
if (state.locateTarget == target) state.copy(locateTarget = null) else state
}
private fun consumeLocateTarget() {
_uiState.update { it.copy(locateTarget = null) }
}
// ---------- Running state ----------
private fun updateRunningState(running: Boolean, clearTestingText: Boolean = true) {
if (!running || clearTestingText) testRequests.invalidateCurrent()
_uiState.update { state ->
state.copy(
isRunning = running,
status = if (!clearTestingText && state.isTesting) state.status
isTesting = testRequests.isTesting,
status = if (!clearTestingText && state.isRunning == running) state.status
else if (running) MainStatus.Connected else MainStatus.Disconnected
)
}
@@ -796,4 +954,8 @@ class MainViewModel(
throw IllegalArgumentException("Unknown ViewModel class")
}
}
private companion object {
const val TEST_RESULT_FLUSH_INTERVAL_MS = 500L
}
}
@@ -41,15 +41,14 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.extension.toastInfo
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppDivider
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
import com.v2ray.ang.ui.compose.AppListItem
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.ConfirmDialog
import com.v2ray.ang.ui.compose.ItemDivider
import com.v2ray.ang.ui.compose.colorFabActive
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.Utils
@@ -88,9 +87,6 @@ class PerAppProxyActivity : BaseComponentActivity() {
onBackClick = { finish() },
onPerAppProxyChanged = { viewModel.setPerAppProxyEnabled(it) },
onBypassAppsChanged = { viewModel.setBypassAppsEnabled(it) },
onInfoClick = {
toastInfo(R.string.summary_pref_per_app_proxy)
},
onToggleApp = { viewModel.toggle(it) },
onSearch = { viewModel.filterApps(it) },
onSelectAll = { viewModel.selectAll() },
@@ -119,7 +115,6 @@ fun PerAppProxyScreen(
onBackClick: () -> Unit,
onPerAppProxyChanged: (Boolean) -> Unit,
onBypassAppsChanged: (Boolean) -> Unit,
onInfoClick: () -> Unit,
onToggleApp: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit,
@@ -131,6 +126,8 @@ fun PerAppProxyScreen(
var showSearch by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
var showInfoDialog by rememberSaveable { mutableStateOf(false) }
val onInfoClick = { showInfoDialog = true }
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
@@ -282,4 +279,13 @@ fun PerAppProxyScreen(
}
}
}
if (showInfoDialog) {
ConfirmDialog(
message = stringResource(R.string.summary_pref_per_app_proxy),
dismissText = null,
onConfirm = {},
onDismiss = { showInfoDialog = false },
)
}
}
@@ -37,7 +37,6 @@ import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.apppicker.AppPickerActivity
@@ -86,7 +85,6 @@ class RoutingEditActivity : BaseComponentActivity() {
private fun saveServer(rulesetItem: RulesetItem): Boolean {
if (rulesetItem.remarks.isNullOrEmpty()) {
toast(R.string.sub_setting_remarks)
return false
}
if (position < 0 && rulesetItem.id.isEmpty()) {
@@ -124,6 +122,7 @@ fun RoutingEditScreen(
val scrollState = rememberScrollState()
var remarks by rememberSaveable { mutableStateOf(initial?.remarks ?: "") }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var locked by rememberSaveable { mutableStateOf(initial?.locked == true) }
var domain by rememberSaveable { mutableStateOf(initial?.domain?.joinToString(",") ?: "") }
var ip by rememberSaveable { mutableStateOf(initial?.ip?.joinToString(",") ?: "") }
@@ -193,7 +192,15 @@ fun RoutingEditScreen(
)
}
}
IconButton(onClick = { onSave(buildRuleset()) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(buildRuleset())
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -216,7 +223,8 @@ fun RoutingEditScreen(
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
SettingsSwitchItem(
title = stringResource(R.string.routing_settings_locked),
@@ -309,6 +317,7 @@ fun RoutingEditScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_routing_rule),
itemName = initial?.remarks.orEmpty(),
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -54,12 +54,11 @@ import com.v2ray.ang.ui.base.HelperBaseComponentActivity
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.ItemDivider
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.ReorderableListItem
import com.v2ray.ang.ui.compose.SelectListDialog
import com.v2ray.ang.ui.compose.SettingsListItem
import com.v2ray.ang.ui.compose.colorConfigType
import com.v2ray.ang.ui.compose.colorFabActive
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
@@ -4,7 +4,6 @@ import android.os.Bundle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -106,20 +105,23 @@ abstract class BaseServerActivity : BaseComponentActivity() {
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FormTextField(
stringResource(R.string.server_lab_remarks),
state.remarks,
{ state.remarks = it }
label = stringResource(R.string.server_lab_remarks),
value = state.remarks,
onValueChange = { state.remarks = it },
isError = state.isRemarksError
)
FormTextField(
stringResource(R.string.server_lab_address),
state.address,
{ state.address = it }
label = stringResource(R.string.server_lab_address),
value = state.address,
onValueChange = { state.address = it },
isError = state.isAddressError
)
FormTextField(
stringResource(R.string.server_lab_port),
state.port,
{ state.port = it },
keyboardType = KeyboardType.Number
label = stringResource(R.string.server_lab_port),
value = state.port,
onValueChange = { state.port = it },
keyboardType = KeyboardType.Number,
isError = state.isPortError
)
}
}
@@ -366,33 +368,27 @@ abstract class BaseServerActivity : BaseComponentActivity() {
}
protected fun validateBasicConfig(state: ServerUiState): Boolean {
if (state.remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
if (state.address.isBlank()) {
toast(R.string.server_lab_address)
return false
}
if (
state.configType != EConfigType.HYSTERIA2 &&
(state.port.toIntOrNull() ?: 0) <= 0
) {
toast(R.string.server_lab_port)
return false
}
return true
val remarksErr = state.remarks.isBlank()
val addressErr = state.address.isBlank()
val portErr = state.configType != EConfigType.HYSTERIA2 && (state.port.toIntOrNull() ?: 0) <= 0
state.isRemarksError = remarksErr
state.isAddressError = addressErr
state.isPortError = portErr
val hasError = remarksErr || addressErr || portErr
return !hasError
}
protected open fun validateProtocolConfig(config: ProfileItem): Boolean = true
protected open fun validateCommonConfig(config: ProfileItem): Boolean {
protected open fun validateCommonConfig(state: ServerUiState, config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
state.isPasswordError = true
if (config.configType == EConfigType.VMESS ||
config.configType == EConfigType.VLESS
) {
toast(R.string.server_lab_id)
return false
}
@@ -400,7 +396,6 @@ abstract class BaseServerActivity : BaseComponentActivity() {
config.configType == EConfigType.SHADOWSOCKS ||
config.configType == EConfigType.HYSTERIA2
) {
toast(R.string.server_lab_id3)
return false
}
}
@@ -426,7 +421,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
protected fun saveServer(state: ServerUiState): Boolean {
if (!validateBasicConfig(state)) return false
val config = state.toProfileItem(initialConfig)
if (!validateCommonConfig(config)) return false
if (!validateCommonConfig(state, config)) return false
if (!validateProtocolConfig(config)) return false
config.description = AngConfigManager.generateDescription(config)
@@ -492,6 +487,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
if (showDeleteDialog) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialConfig.remarks,
onConfirm = {
showDeleteDialog = false
deleteServer(editGuid)
@@ -0,0 +1,14 @@
package com.v2ray.ang.ui.server
/** Removes one draft member and its row key together, resolving its current position at confirmation. */
internal fun withoutProxyChainMember(
members: List<String>,
memberKeys: List<String>,
memberKey: String,
): Pair<List<String>, List<String>> {
val index = memberKeys.indexOf(memberKey)
if (index < 0) return members to memberKeys
return members.toMutableList().also { it.removeAt(index) } to
memberKeys.toMutableList().also { it.removeAt(index) }
}
@@ -1,526 +0,0 @@
package com.v2ray.ang.ui.server
import android.os.Bundle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.v2ray.ang.AppConfig.DEFAULT_PORT
import com.v2ray.ang.AppConfig.REALITY
import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.CertificateFingerprintManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.JsonUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ServerActivity : BaseComponentActivity() {
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
private val isRunning by lazy {
intent.getBooleanExtra("isRunning", false)
&& editGuid.isNotEmpty()
&& editGuid == MmkvManager.getSelectServer()
}
private val createConfigType by lazy {
EConfigType.fromInt(intent.getIntExtra("createConfigType", EConfigType.VMESS.value))
?: EConfigType.VMESS
}
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
private lateinit var initialConfig: ProfileItem
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val existingConfig = MmkvManager.decodeServerConfig(editGuid)
initialConfig = existingConfig ?: ProfileItem.create(createConfigType)
}
@Composable
override fun ScreenContent() {
ServerScreen(
guid = editGuid,
configType = initialConfig.configType,
initialConfig = initialConfig,
isRunning = isRunning,
onBackClick = { finish() },
onSave = { saveServer(it) },
onDelete = { deleteServer(editGuid, isRunning) }
)
}
private fun saveServer(config: ProfileItem): Boolean {
if (config.remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
if (config.server.isNullOrBlank()) {
toast(R.string.server_lab_address)
return false
}
if (
config.configType != EConfigType.HYSTERIA2 &&
(config.serverPort?.toIntOrNull() ?: 0) <= 0
) {
toast(R.string.server_lab_port)
return false
}
if (
config.configType != EConfigType.SOCKS &&
config.configType != EConfigType.HTTP &&
config.password.isNullOrBlank()
) {
val message = when (config.configType) {
EConfigType.TROJAN,
EConfigType.SHADOWSOCKS,
EConfigType.HYSTERIA2 -> R.string.server_lab_id3
else -> R.string.server_lab_id
}
toast(message)
return false
}
if (
config.configType == EConfigType.TROJAN &&
config.security.isNullOrBlank()
) {
toast(R.string.server_lab_stream_security)
return false
}
if (
!config.xhttpExtra.isNullOrBlank() &&
JsonUtil.parseString(config.xhttpExtra) == null
) {
toast(R.string.server_lab_xhttp_extra)
return false
}
if (
!config.finalMask.isNullOrBlank() &&
JsonUtil.parseString(config.finalMask) == null
) {
toast(R.string.server_lab_final_mask)
return false
}
config.description =
AngConfigManager.generateDescription(config)
if (
config.subscriptionId.isEmpty() &&
!subscriptionId.isNullOrEmpty()
) {
config.subscriptionId = subscriptionId.orEmpty()
}
val savedGuid = MmkvManager.encodeServerConfig(
editGuid,
config
)
toastSuccess(R.string.toast_success)
ProfileEditorResult.run {
finishSaved(
guid = savedGuid,
restartService = isRunning
)
}
return true
}
private fun deleteServer(
guid: String,
isRunning: Boolean
) {
if (
guid.isEmpty() ||
guid == MmkvManager.getSelectServer()
) {
toast(R.string.toast_action_not_allowed)
return
}
MmkvManager.removeServer(guid)
ProfileEditorResult.run {
finishDeleted(guid)
}
}
}
@Composable
fun ServerScreen(
guid: String,
configType: EConfigType,
initialConfig: ProfileItem,
isRunning: Boolean,
onBackClick: () -> Unit,
onSave: (ProfileItem) -> Boolean,
onDelete: () -> Unit
) {
val context = LocalContext.current
val securityOptions = stringArrayResource(R.array.securitys).toList()
val ssSecurityOptions = stringArrayResource(R.array.ss_securitys).toList()
val flowOptions = stringArrayResource(R.array.flows).toList()
val networkOptions = stringArrayResource(R.array.networks).toList()
val tcpHeaderOptions = stringArrayResource(R.array.header_type_tcp).toList()
val kcpHeaderOptions = stringArrayResource(R.array.header_type_kcp_and_quic).toList()
val grpcModeOptions = stringArrayResource(R.array.mode_type_grpc).toList()
val xhttpModeOptions = stringArrayResource(R.array.xhttp_mode).toList()
val streamSecurityOptions = stringArrayResource(R.array.streamsecurityxs).toList()
val uTlsOptions = stringArrayResource(R.array.streamsecurity_utls).toList()
val alpnOptions = stringArrayResource(R.array.streamsecurity_alpn).toList()
val browserDialerOptions = stringArrayResource(R.array.browser_dialer_mode_value).toList()
var remarks by rememberSaveable { mutableStateOf(initialConfig.remarks) }
var address by rememberSaveable { mutableStateOf(initialConfig.server ?: "") }
var port by rememberSaveable { mutableStateOf(initialConfig.serverPort ?: DEFAULT_PORT.toString()) }
var password by rememberSaveable { mutableStateOf(initialConfig.password ?: "") }
var method by rememberSaveable { mutableStateOf(initialConfig.method ?: "") }
var flow by rememberSaveable { mutableStateOf(initialConfig.flow ?: "") }
var encryption by rememberSaveable { mutableStateOf(initialConfig.method ?: "") }
var username by rememberSaveable { mutableStateOf(initialConfig.username ?: "") }
var secretKey by rememberSaveable { mutableStateOf(initialConfig.secretKey ?: "") }
var publicKey by rememberSaveable { mutableStateOf(initialConfig.publicKey ?: "") }
var preSharedKey by rememberSaveable { mutableStateOf(initialConfig.preSharedKey ?: "") }
var reserved by rememberSaveable { mutableStateOf(initialConfig.reserved ?: "0,0,0") }
var localAddress by rememberSaveable { mutableStateOf(initialConfig.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4) }
var mtu by rememberSaveable { mutableStateOf(initialConfig.mtu?.toString() ?: WIREGUARD_LOCAL_MTU) }
var obfsPassword by rememberSaveable { mutableStateOf(initialConfig.obfsPassword ?: "") }
var portHopping by rememberSaveable { mutableStateOf(initialConfig.portHopping ?: "") }
var portHoppingInterval by rememberSaveable { mutableStateOf(initialConfig.portHoppingInterval ?: "") }
var bandwidthDown by rememberSaveable { mutableStateOf(initialConfig.bandwidthDown ?: "") }
var bandwidthUp by rememberSaveable { mutableStateOf(initialConfig.bandwidthUp ?: "") }
var network by rememberSaveable { mutableStateOf(initialConfig.network ?: NetworkType.TCP.type) }
var headerType by rememberSaveable { mutableStateOf(initialConfig.headerType ?: "none") }
var host by rememberSaveable { mutableStateOf(initialConfig.host ?: "") }
var path by rememberSaveable { mutableStateOf(initialConfig.path ?: "") }
var seed by rememberSaveable { mutableStateOf(initialConfig.seed ?: "") }
var quicSecurity by rememberSaveable { mutableStateOf(initialConfig.quicSecurity ?: "") }
var quicKey by rememberSaveable { mutableStateOf(initialConfig.quicKey ?: "") }
var mode by rememberSaveable { mutableStateOf(initialConfig.mode ?: "") }
var serviceName by rememberSaveable { mutableStateOf(initialConfig.serviceName ?: "") }
var authority by rememberSaveable { mutableStateOf(initialConfig.authority ?: "") }
var xhttpMode by rememberSaveable { mutableStateOf(initialConfig.xhttpMode ?: "") }
var xhttpExtra by rememberSaveable { mutableStateOf(initialConfig.xhttpExtra ?: "") }
var finalMask by rememberSaveable { mutableStateOf(initialConfig.finalMask ?: "") }
var kcpMtu by rememberSaveable { mutableStateOf(initialConfig.kcpMtu?.toString() ?: "") }
var kcpTti by rememberSaveable { mutableStateOf(initialConfig.kcpTti?.toString() ?: "") }
var browserDialerMode by rememberSaveable { mutableStateOf(initialConfig.browserDialerMode ?: "") }
var streamSecurity by rememberSaveable { mutableStateOf(initialConfig.security ?: "") }
var sni by rememberSaveable { mutableStateOf(initialConfig.sni ?: "") }
var allowInsecure by rememberSaveable { mutableStateOf(initialConfig.insecure == true) }
var fingerPrint by rememberSaveable { mutableStateOf(initialConfig.fingerPrint ?: "") }
var alpn by rememberSaveable { mutableStateOf(initialConfig.alpn ?: "") }
var publicKeyReality by rememberSaveable { mutableStateOf(initialConfig.publicKey ?: "") }
var shortId by rememberSaveable { mutableStateOf(initialConfig.shortId ?: "") }
var spiderX by rememberSaveable { mutableStateOf(initialConfig.spiderX ?: "") }
var mldsa65Verify by rememberSaveable { mutableStateOf(initialConfig.mldsa65Verify ?: "") }
var echConfigList by rememberSaveable { mutableStateOf(initialConfig.echConfigList ?: "") }
var verifyPeerCertByName by rememberSaveable { mutableStateOf(initialConfig.verifyPeerCertByName ?: "") }
var pinnedCA256 by rememberSaveable { mutableStateOf(initialConfig.pinnedCA256 ?: "") }
val isVmess = configType == EConfigType.VMESS
val isVless = configType == EConfigType.VLESS
val isShadowsocks = configType == EConfigType.SHADOWSOCKS
val isSocksOrHttp = configType == EConfigType.SOCKS || configType == EConfigType.HTTP
val isTrojan = configType == EConfigType.TROJAN
val isWireguard = configType == EConfigType.WIREGUARD
val isHysteria2 = configType == EConfigType.HYSTERIA2
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
var isFetchingCert by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
fun buildProfileItem(): ProfileItem = initialConfig.copy(
remarks = remarks,
server = address,
serverPort = port,
password = password,
method = when {
isVmess || isShadowsocks -> method
isVless -> encryption
else -> null
},
flow = if (isVless) flow else null,
username = if (isSocksOrHttp) username else null,
secretKey = if (isWireguard) secretKey else null,
publicKey = when {
isWireguard -> publicKey
streamSecurity == REALITY -> publicKeyReality
else -> null
},
preSharedKey = if (isWireguard) preSharedKey else null,
reserved = if (isWireguard) reserved else null,
localAddress = if (isWireguard) localAddress else null,
mtu = if (isWireguard) mtu.toIntOrNull() else null,
obfsPassword = if (isHysteria2) obfsPassword else null,
portHopping = if (isHysteria2) portHopping else null,
portHoppingInterval = if (isHysteria2) portHoppingInterval else null,
bandwidthDown = if (isHysteria2) bandwidthDown else null,
bandwidthUp = if (isHysteria2) bandwidthUp else null,
network = network,
headerType = headerType,
host = host,
path = path,
seed = seed,
quicSecurity = quicSecurity,
quicKey = quicKey,
mode = mode,
serviceName = serviceName,
authority = authority,
xhttpMode = xhttpMode,
xhttpExtra = xhttpExtra.nullIfBlank(),
finalMask = finalMask.nullIfBlank(),
kcpMtu = kcpMtu.toIntOrNull(),
kcpTti = kcpTti.toIntOrNull(),
browserDialerMode = if (network in listOf(NetworkType.WS.type, NetworkType.XHTTP.type)) browserDialerMode.nullIfBlank() else null,
security = streamSecurity,
sni = sni,
insecure = allowInsecure,
fingerPrint = fingerPrint,
alpn = alpn,
shortId = shortId,
spiderX = spiderX,
mldsa65Verify = mldsa65Verify,
echConfigList = echConfigList,
verifyPeerCertByName = verifyPeerCertByName,
pinnedCA256 = pinnedCA256
)
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = configType.toString(),
onBackClick = onBackClick,
actions = {
if (guid.isNotEmpty() && !isRunning) {
IconButton(onClick = { showDeleteDialog = true }) {
Icon(painterResource(R.drawable.ic_delete_24dp), stringResource(R.string.acc_delete))
}
}
IconButton(onClick = {
onSave(buildProfileItem())
}) {
Icon(painterResource(R.drawable.ic_fab_check), stringResource(R.string.acc_save))
}
}
)
}
) { innerPadding ->
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.imePadding()
.verticalScrollbar(listState),
contentPadding = PaddingValues(bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it }) }
item { FormTextField(stringResource(R.string.server_lab_address), address, { address = it }) }
if (configType != EConfigType.HYSTERIA2) item { FormTextField(stringResource(R.string.server_lab_port), port, { port = it }, keyboardType = KeyboardType.Number) }
when {
isVmess || isTrojan || isShadowsocks || isHysteria2 -> item {
FormTextField(
stringResource(
when {
isTrojan || isShadowsocks || isHysteria2 -> R.string.server_lab_id3
else -> R.string.server_lab_id
}
), password, { password = it })
}
isVless -> {
item { FormTextField(stringResource(R.string.server_lab_id), password, { password = it }) }
item { FormTextField(stringResource(R.string.server_lab_encryption), encryption, { encryption = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_flow), flow, flowOptions, { flow = it }) }
}
isSocksOrHttp -> {
item { FormTextField(stringResource(R.string.server_lab_security4), username, { username = it }) }
item { FormTextField(stringResource(R.string.server_lab_id4), password, { password = it }) }
}
}
if (isVmess) item { FormDropdownField(stringResource(R.string.server_lab_security), method, securityOptions, { method = it }) }
if (isShadowsocks) item { FormDropdownField(stringResource(R.string.server_lab_security), method, ssSecurityOptions, { method = it }) }
if (isWireguard) {
item { FormTextField(stringResource(R.string.server_lab_secret_key), secretKey, { secretKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_public_key), publicKey, { publicKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_preshared_key), preSharedKey, { preSharedKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_reserved), reserved, { reserved = it }) }
item { FormTextField(stringResource(R.string.server_lab_local_address), localAddress, { localAddress = it }) }
item { FormTextField(stringResource(R.string.server_lab_local_mtu), mtu, { mtu = it }, keyboardType = KeyboardType.Number) }
}
if (isHysteria2) {
item { FormTextField(stringResource(R.string.server_obfs_password), obfsPassword, { obfsPassword = it }) }
item { FormTextField(stringResource(R.string.server_lab_port_hop), portHopping, { portHopping = it }) }
item { FormTextField(stringResource(R.string.server_lab_port_hop_interval), portHoppingInterval, { portHoppingInterval = it }) }
item { FormTextField(stringResource(R.string.server_lab_bandwidth_down), bandwidthDown, { bandwidthDown = it }) }
item { FormTextField(stringResource(R.string.server_lab_bandwidth_up), bandwidthUp, { bandwidthUp = it }) }
}
item { FormDropdownField(stringResource(R.string.server_lab_network), network, networkOptions, { network = it }) }
val headerOptions = when (network) {
NetworkType.TCP.type -> tcpHeaderOptions
NetworkType.KCP.type -> kcpHeaderOptions
NetworkType.GRPC.type -> grpcModeOptions
NetworkType.XHTTP.type -> xhttpModeOptions
else -> listOf("---")
}
if (headerOptions.size > 1) {
item {
FormDropdownField(
stringResource(
when (network) {
NetworkType.GRPC.type -> R.string.server_lab_mode_type; NetworkType.XHTTP.type -> R.string.server_lab_xhttp_mode; else -> R.string.server_lab_head_type
}
),
headerType, headerOptions, { headerType = it }
)
}
}
item {
FormTextField(
stringResource(
when (network) {
NetworkType.TCP.type, NetworkType.HTTP_UPGRADE.type, NetworkType.XHTTP.type, NetworkType.H2.type -> R.string.server_lab_request_host_http
NetworkType.WS.type -> R.string.server_lab_request_host_ws
NetworkType.GRPC.type -> R.string.server_lab_request_host_grpc
else -> R.string.server_lab_request_host6
}
), host, { host = it })
}
item {
FormTextField(
stringResource(
when (network) {
NetworkType.KCP.type -> R.string.server_lab_path_kcp
NetworkType.WS.type -> R.string.server_lab_path_ws
NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
NetworkType.H2.type -> R.string.server_lab_path_h2
NetworkType.GRPC.type -> R.string.server_lab_path_grpc
else -> R.string.server_lab_path
}
), path, { path = it })
}
if (network == NetworkType.XHTTP.type) {
item { FormTextField(stringResource(R.string.server_lab_xhttp_extra), xhttpExtra, { xhttpExtra = it }) }
}
if (network == NetworkType.KCP.type) {
item { FormTextField(stringResource(R.string.server_lab_kcp_mtu), kcpMtu, { kcpMtu = it }, keyboardType = KeyboardType.Number) }
item { FormTextField(stringResource(R.string.server_lab_kcp_tti), kcpTti, { kcpTti = it }, keyboardType = KeyboardType.Number) }
}
item { FormTextField(stringResource(R.string.server_lab_final_mask), finalMask, { finalMask = it }) }
if (network == NetworkType.WS.type || network == NetworkType.XHTTP.type) {
item { FormDropdownField(stringResource(R.string.server_lab_browser_dialer), browserDialerMode, browserDialerOptions, { browserDialerMode = it }) }
}
item { FormDropdownField(stringResource(R.string.server_lab_stream_security), streamSecurity, streamSecurityOptions, { streamSecurity = it }) }
if (streamSecurity.isNotBlank()) {
item { FormTextField(stringResource(R.string.server_lab_sni), sni, { sni = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_stream_fingerprint), fingerPrint, uTlsOptions, { fingerPrint = it }) }
if (streamSecurity == TLS) {
item { SettingsSwitchItem(title = stringResource(R.string.server_lab_allow_insecure), checked = allowInsecure, onCheckedChange = { allowInsecure = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_stream_alpn), alpn, alpnOptions, { alpn = it }) }
item { FormTextField(stringResource(R.string.server_lab_ech_config_list), echConfigList, { echConfigList = it }) }
item { FormTextField(stringResource(R.string.server_lab_verify_peer_cert_by_name), verifyPeerCertByName, { verifyPeerCertByName = it }) }
item { FormTextField(stringResource(R.string.server_lab_pinned_ca256), pinnedCA256, { pinnedCA256 = it }) }
item {
Button(
onClick = {
if (address.isBlank()) {
context.toast(R.string.server_lab_address); return@Button
}
if (configType != EConfigType.HYSTERIA2 && (port.toIntOrNull() ?: 0) <= 0) {
context.toast(R.string.server_lab_port); return@Button
}
val temp = buildProfileItem()
scope.launch {
isFetchingCert = true
try {
val sha256 = withContext(Dispatchers.IO) { CertificateFingerprintManager.fetchForManualFill(temp) }
if (sha256.isNullOrBlank()) context.toast(R.string.toast_fetch_cert_sha256_failed) else {
pinnedCA256 = sha256
context.toastSuccess(R.string.toast_fetch_cert_sha256_success)
}
} finally {
isFetchingCert = false
}
}
},
enabled = !isFetchingCert,
modifier = Modifier.padding(start = 16.dp)
) { Text(stringResource(R.string.pinned_ca256_action_fetch)) }
}
} else if (streamSecurity == REALITY) {
item { FormTextField(stringResource(R.string.server_lab_public_key), publicKeyReality, { publicKeyReality = it }) }
item { FormTextField(stringResource(R.string.server_lab_short_id), shortId, { shortId = it }) }
item { FormTextField(stringResource(R.string.server_lab_spider_x), spiderX, { spiderX = it }) }
item { FormTextField(stringResource(R.string.server_lab_mldsa65_verify), mldsa65Verify, { mldsa65Verify = it }) }
}
}
}
}
if (showDeleteDialog) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
onConfirm = { showDeleteDialog = false; onDelete() },
onDismiss = { showDeleteDialog = false }
)
}
}
@@ -6,7 +6,6 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
@@ -110,7 +109,6 @@ class ServerCustomConfigActivity : BaseComponentActivity() {
content: String
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -208,6 +206,7 @@ fun ServerCustomConfigScreen(
onDelete: () -> Unit
) {
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
val textFieldState = rememberTextFieldState(initialText = initialContent)
var showDeleteConfirm by remember { mutableStateOf(false) }
val showDelete = editGuid.isNotEmpty() && !isRunning
@@ -326,7 +325,15 @@ fun ServerCustomConfigScreen(
)
}
}
IconButton(onClick = { onSave(remarks, textFieldState.text.toString()) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(remarks, textFieldState.text.toString())
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -346,7 +353,8 @@ fun ServerCustomConfigScreen(
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
Box(
@@ -487,6 +495,7 @@ fun ServerCustomConfigScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialRemarks,
onConfirm = {
showDeleteConfirm = false
onDelete()
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui.server
import android.os.Bundle
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -117,7 +116,6 @@ class ServerGroupActivity : BaseComponentActivity() {
fallbackTag: String,
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -228,6 +226,7 @@ fun ServerGroupScreen(
val typeEntries = stringArrayResource(R.array.policy_group_type).toList()
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var filter by rememberSaveable { mutableStateOf(initialFilter) }
var typeValue by rememberSaveable { mutableStateOf(typeEntries.getOrNull(initialType).orEmpty()) }
var subValue by rememberSaveable { mutableStateOf(subDisplay.getOrNull(initialSubIndex).orEmpty()) }
@@ -251,9 +250,15 @@ fun ServerGroupScreen(
}
}
IconButton(onClick = {
val typeIdx = typeEntries.indexOf(typeValue).coerceAtLeast(0)
val subIdx = subDisplay.indexOf(subValue).coerceAtLeast(0)
onSave(remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag)
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
val typeIdx = typeEntries.indexOf(typeValue).coerceAtLeast(0)
val subIdx = subDisplay.indexOf(subValue).coerceAtLeast(0)
onSave(remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag)
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
@@ -270,7 +275,12 @@ fun ServerGroupScreen(
.padding(vertical = 8.dp)
.verticalScroll(rememberScrollState())
) {
FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it })
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it },
isError = isRemarksError
)
FormDropdownField(
label = stringResource(R.string.title_policy_group_type),
value = typeValue,
@@ -307,6 +317,7 @@ fun ServerGroupScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_policy_group),
itemName = initialRemarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -1,14 +1,12 @@
package com.v2ray.ang.ui.server
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormTextField
import com.v2ray.ang.ui.compose.SettingsSwitchItem
@@ -18,7 +16,6 @@ class ServerHysteria2Activity : BaseServerActivity() {
@Composable
override fun ScreenContent() {
val scope = rememberCoroutineScope()
val uiState = rememberSaveable(saver = ServerUiState.Saver) {
ServerUiState.from(
initialConfig = initialConfig
@@ -39,7 +36,6 @@ class ServerHysteria2Activity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id3)
return false
}
if (config.security.isNullOrBlank()) {
@@ -51,9 +47,10 @@ class ServerHysteria2Activity : BaseServerActivity() {
@Composable
private fun Hysteria2ProtocolFields(state: ServerUiState) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormTextField(
stringResource(R.string.server_obfs_password),
@@ -91,6 +88,11 @@ class ServerHysteria2Activity : BaseServerActivity() {
state.sni,
{ state.sni = it }
)
FormTextField(
stringResource(R.string.server_lab_ech_config_list),
state.echConfigList,
{ state.echConfigList = it }
)
FormTextField(
stringResource(R.string.server_lab_pinned_ca256),
state.pinnedCA256,
@@ -100,7 +100,6 @@ class ServerProxyChainActivity : BaseComponentActivity() {
members: List<String>
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -200,10 +199,11 @@ fun ProxyChainScreen(
onDelete: () -> Unit
) {
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var members by rememberSaveable { mutableStateOf(initialMembers) }
var memberKeys by rememberSaveable { mutableStateOf(List(initialMembers.size) { UUID.randomUUID().toString() }) }
var showProfileDeleteConfirm by remember { mutableStateOf(false) }
var memberToDeleteIndex by rememberSaveable { mutableStateOf<Int?>(null) }
var memberToDeleteKey by rememberSaveable { mutableStateOf<String?>(null) }
val showDelete = editGuid.isNotEmpty() && !isRunning
val lazyListState = rememberLazyListState()
@@ -233,7 +233,15 @@ fun ProxyChainScreen(
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.acc_delete))
}
}
IconButton(onClick = { onSave(remarks, members) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(remarks, members)
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
}
@@ -272,7 +280,8 @@ fun ProxyChainScreen(
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
}
@@ -285,7 +294,8 @@ fun ProxyChainScreen(
}
itemsIndexed(items = members, key = { index, _ -> memberKeys[index] }) { index, member ->
ReorderableItem(reorderableState, key = memberKeys[index]) { isDragging ->
val memberKey = memberKeys[index]
ReorderableItem(reorderableState, key = memberKey) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row(
@@ -314,10 +324,11 @@ fun ProxyChainScreen(
)
IconButton(onClick = {
if (member.isBlank()) {
members = members.toMutableList().also { it.removeAt(index) }
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
members = remainingMembers
memberKeys = remainingKeys
} else {
memberToDeleteIndex = index
memberToDeleteKey = memberKey
}
}) {
Icon(
@@ -335,19 +346,22 @@ fun ProxyChainScreen(
if (showProfileDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialRemarks,
onConfirm = { showProfileDeleteConfirm = false; onDelete() },
onDismiss = { showProfileDeleteConfirm = false }
)
}
memberToDeleteIndex?.let { index ->
memberToDeleteKey?.let { memberKey ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_proxy_chain_member),
itemName = members.getOrNull(memberKeys.indexOf(memberKey)).orEmpty(),
onConfirm = {
members = members.toMutableList().also { it.removeAt(index) }
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
memberToDeleteIndex = null
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
members = remainingMembers
memberKeys = remainingKeys
memberToDeleteKey = null
},
onDismiss = { memberToDeleteIndex = null }
onDismiss = { memberToDeleteKey = null }
)
}
}
@@ -52,9 +52,10 @@ class ServerShadowsocksActivity : BaseServerActivity() {
methodOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormDropdownField(
stringResource(R.string.server_lab_security),
@@ -7,7 +7,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormTextField
class ServerTrojanActivity : BaseServerActivity() {
@@ -44,11 +43,9 @@ class ServerTrojanActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id3)
return false
}
if (config.security.isNullOrBlank()) {
toast(R.string.server_lab_stream_security)
return false
}
return true
@@ -57,9 +54,10 @@ class ServerTrojanActivity : BaseServerActivity() {
@Composable
private fun TrojanProtocolFields(state: ServerUiState) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
}
}
@@ -8,6 +8,7 @@ import com.v2ray.ang.AppConfig.DEFAULT_PORT
import com.v2ray.ang.AppConfig.REALITY
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
@@ -30,6 +31,7 @@ class ServerUiState(
reserved: String = "0,0,0",
localAddress: String = WIREGUARD_LOCAL_ADDRESS_V4,
mtu: String = WIREGUARD_LOCAL_MTU,
remoteDNS: String = WIREGUARD_LOCAL_REMOTE_DNS,
obfsPassword: String = "",
portHopping: String = "",
portHoppingInterval: String = "",
@@ -78,6 +80,7 @@ class ServerUiState(
var reserved by mutableStateOf(reserved)
var localAddress by mutableStateOf(localAddress)
var mtu by mutableStateOf(mtu)
var remoteDNS by mutableStateOf(remoteDNS)
var obfsPassword by mutableStateOf(obfsPassword)
var portHopping by mutableStateOf(portHopping)
var portHoppingInterval by mutableStateOf(portHoppingInterval)
@@ -111,6 +114,11 @@ class ServerUiState(
var pinnedCA256 by mutableStateOf(pinnedCA256)
var isFetchingCert by mutableStateOf(isFetchingCert)
var isRemarksError by mutableStateOf(false)
var isAddressError by mutableStateOf(false)
var isPortError by mutableStateOf(false)
var isPasswordError by mutableStateOf(false)
fun toProfileItem(initialConfig: ProfileItem): ProfileItem {
val isVmess = configType == EConfigType.VMESS
val isVless = configType == EConfigType.VLESS
@@ -142,6 +150,7 @@ class ServerUiState(
reserved = if (isWireguard) reserved else null,
localAddress = if (isWireguard) localAddress else null,
mtu = if (isWireguard) mtu.toIntOrNull() else null,
remoteDNS = if (isWireguard) remoteDNS else null,
obfsPassword = if (isHysteria2) obfsPassword else null,
portHopping = if (isHysteria2) portHopping else null,
portHoppingInterval = if (isHysteria2) portHoppingInterval else null,
@@ -199,6 +208,7 @@ class ServerUiState(
reserved = initialConfig.reserved ?: "0,0,0",
localAddress = initialConfig.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4,
mtu = initialConfig.mtu?.toString() ?: WIREGUARD_LOCAL_MTU,
remoteDNS = initialConfig.remoteDNS ?: WIREGUARD_LOCAL_REMOTE_DNS,
obfsPassword = initialConfig.obfsPassword ?: "",
portHopping = initialConfig.portHopping ?: "",
portHoppingInterval = initialConfig.portHoppingInterval ?: "",
@@ -8,7 +8,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
@@ -47,7 +46,6 @@ class ServerVlessActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id)
return false
}
return true
@@ -59,9 +57,10 @@ class ServerVlessActivity : BaseServerActivity() {
flowOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormTextField(
stringResource(R.string.server_lab_encryption),
@@ -8,7 +8,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
@@ -47,7 +46,6 @@ class ServerVmessActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id)
return false
}
return true
@@ -59,9 +57,10 @@ class ServerVmessActivity : BaseServerActivity() {
methodOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormDropdownField(
stringResource(R.string.server_lab_security),
@@ -73,6 +73,12 @@ class ServerWireguardActivity : BaseServerActivity() {
state.finalMask,
{ state.finalMask = it }
)
FormTextField(
stringResource(R.string.server_lab_remote_dns),
state.remoteDNS,
{ state.remoteDNS = it }
)
}
}
@@ -1,7 +1,10 @@
package com.v2ray.ang.ui.settings
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -24,10 +27,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
@@ -44,7 +51,9 @@ import com.v2ray.ang.ui.compose.SettingsMenuItem
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.ThemeManager
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
class SettingsActivity : BaseComponentActivity() {
@@ -52,6 +61,26 @@ class SettingsActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.refreshSystemVpnSettingsAvailability()
}
}
}
private fun openSystemVpnSettings() {
try {
startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
} catch (error: ActivityNotFoundException) {
reportSystemVpnSettingsFailure(error)
} catch (error: SecurityException) {
reportSystemVpnSettingsFailure(error)
}
}
private fun reportSystemVpnSettingsFailure(error: RuntimeException) {
LogUtil.e(AppConfig.TAG, "Cannot open system VPN settings", error)
toastError(R.string.toast_system_vpn_settings_unavailable)
}
@Composable
@@ -59,7 +88,8 @@ class SettingsActivity : BaseComponentActivity() {
SettingsScreen(
viewModel = viewModel,
onBackClick = { finish() },
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) }
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) },
onSystemVpnSettingsClicked = ::openSystemVpnSettings
)
}
}
@@ -69,10 +99,12 @@ class SettingsActivity : BaseComponentActivity() {
fun SettingsScreen(
viewModel: SettingsViewModel,
onBackClick: () -> Unit,
onModeHelpClicked: () -> Unit
onModeHelpClicked: () -> Unit,
onSystemVpnSettingsClicked: () -> Unit
) {
val scrollState = rememberScrollState()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val systemVpnSettingsAvailable by viewModel.systemVpnSettingsAvailable.collectAsStateWithLifecycle()
var uiSettingsExpanded by rememberSaveable { mutableStateOf(true) }
var vpnSettingsExpanded by rememberSaveable { mutableStateOf(true) }
var coreSettingsExpanded by rememberSaveable { mutableStateOf(true) }
@@ -86,13 +118,13 @@ fun SettingsScreen(
var fakeDns by rememberMmkvBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
var appendHttpProxy by rememberMmkvBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
var vpnDns by rememberMmkvString(AppConfig.PREF_VPN_DNS, "")
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, "0")
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
var vpnInterfaceAddress by rememberMmkvString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")
var vpnMtu by rememberMmkvString(AppConfig.PREF_VPN_MTU, "")
var mux by rememberMmkvBool(AppConfig.PREF_MUX_ENABLED, false)
var muxConcurrency by rememberMmkvString(AppConfig.PREF_MUX_CONCURRENCY, "8")
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
var muxXudpQuic by rememberMmkvString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
var fragment by rememberMmkvBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
@@ -110,7 +142,7 @@ fun SettingsScreen(
var enableRootMode by rememberMmkvBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
var lanSharing by rememberMmkvBool(AppConfig.PREF_ROOT_LAN_SHARING, false)
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, "warning")
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)
var hevTunRwTimeout by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, "")
var useHevTun by rememberMmkvBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
@@ -119,7 +151,7 @@ fun SettingsScreen(
var dynamicSocksPort by rememberMmkvBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
var socksUsername by rememberMmkvString(AppConfig.PREF_SOCKS_USERNAME, "")
var socksPassword by rememberMmkvString(AppConfig.PREF_SOCKS_PASSWORD, "")
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, false)
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
var proxySharing by rememberMmkvBool(AppConfig.PREF_PROXY_SHARING, false)
var speedEnabled by rememberMmkvBool(AppConfig.PREF_SPEED_ENABLED, false)
@@ -142,7 +174,7 @@ fun SettingsScreen(
var domesticDns by rememberMmkvString(AppConfig.PREF_DOMESTIC_DNS, "")
var dnsHosts by rememberMmkvString(AppConfig.PREF_DNS_HOSTS, "")
var coreLogLevel by rememberMmkvString(AppConfig.PREF_LOGLEVEL, "warning")
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "0")
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD)
var isBooted by rememberMmkvBool(AppConfig.PREF_IS_BOOTED, false)
var delayTestUrl by rememberMmkvString(AppConfig.PREF_DELAY_TEST_URL, "")
@@ -153,7 +185,7 @@ fun SettingsScreen(
val hevTunEnabled = isVpn && useHevTun
val localProxyForced = hevTunEnabled
val effectiveLocalProxy = enableLocalProxy || localProxyForced
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: 8
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY.toInt()
val dynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
LaunchedEffect(dynamicColorSupported) {
@@ -612,6 +644,13 @@ fun SettingsScreen(
checked = isBooted,
onCheckedChange = { isBooted = it }
)
if (systemVpnSettingsAvailable) {
SettingsMenuItem(
title = stringResource(R.string.title_system_vpn_settings),
subtitle = stringResource(R.string.summary_system_vpn_settings),
onClick = onSystemVpnSettingsClicked
)
}
SettingsEditItem(
title = stringResource(R.string.title_pref_delay_test_url),
value = delayTestUrl,
@@ -1,15 +1,29 @@
package com.v2ray.ang.ui.settings
import android.app.Application
import android.content.Intent
import android.provider.Settings
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.root.RootManager
import com.v2ray.ang.ui.base.BaseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
class SettingsViewModel(application: Application) : BaseViewModel(application) {
private val _systemVpnSettingsAvailable = MutableStateFlow(false)
val systemVpnSettingsAvailable = _systemVpnSettingsAvailable.asStateFlow()
suspend fun refreshSystemVpnSettingsAvailability() {
_systemVpnSettingsAvailable.value = withContext(Dispatchers.IO) {
// Android exposes the VPN page, not a direct link to the Always-on VPN switch.
Intent(Settings.ACTION_VPN_SETTINGS).resolveActivity(getApplication<Application>().packageManager) != null
}
}
/**
* Checks for root access and requests it if necessary.
* Updates [isLoading] during the process.
@@ -56,4 +70,4 @@ class SettingsViewModel(application: Application) : BaseViewModel(application) {
null
}
}
}
}
@@ -34,15 +34,19 @@ import com.v2ray.ang.R
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.LogUtil
data class TaskerItem(
val label: String,
val guid: String,
)
class TaskerActivity : BaseComponentActivity() {
private var lstData: ArrayList<String> = ArrayList()
private var lstGuid: ArrayList<String> = ArrayList()
private val items = mutableListOf<TaskerItem>()
private val switchState = mutableStateOf(false)
private val selectedPosition = mutableStateOf(-1)
@@ -50,13 +54,11 @@ class TaskerActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
items.add(TaskerItem(label = "Default", guid = AppConfig.TASKER_DEFAULT_GUID))
MmkvManager.decodeAllServerList().forEach { key ->
MmkvManager.decodeServerConfig(key)?.let { config ->
lstData.add(config.remarks)
lstGuid.add(key)
items.add(TaskerItem(label = config.remarks, guid = key))
}
}
@@ -66,7 +68,7 @@ class TaskerActivity : BaseComponentActivity() {
@Composable
override fun ScreenContent() {
TaskerScreen(
items = lstData,
items = items,
switchState = switchState,
selectedPosition = selectedPosition,
onBackClick = { finish() },
@@ -84,10 +86,7 @@ class TaskerActivity : BaseComponentActivity() {
return
} else {
switchState.value = switch
val pos = lstGuid.indexOf(guid.toString())
if (pos >= 0) {
selectedPosition.value = pos
}
selectedPosition.value = items.indexOfFirst { it.guid == guid.toString() }
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
@@ -102,13 +101,12 @@ class TaskerActivity : BaseComponentActivity() {
val extraBundle = Bundle()
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, switchState.value)
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, items[position].guid)
val intent = Intent()
val remarks = lstData[position]
val blurb = getString(
if (switchState.value) R.string.tasker_blurb_start else R.string.tasker_blurb_stop,
remarks
items[position].label
)
intent.putExtra(AppConfig.TASKER_EXTRA_BUNDLE, extraBundle)
@@ -120,7 +118,7 @@ class TaskerActivity : BaseComponentActivity() {
@Composable
fun TaskerScreen(
items: List<String>,
items: List<TaskerItem>,
switchState: MutableState<Boolean>,
selectedPosition: MutableState<Int>,
onBackClick: () -> Unit,
@@ -158,7 +156,7 @@ fun TaskerScreen(
.verticalScrollbar(listState),
contentPadding = NavigationBarsBottomPadding()
) {
itemsIndexed(items) { index, remarks ->
itemsIndexed(items, key = { _, item -> item.guid }) { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
@@ -171,7 +169,7 @@ fun TaskerScreen(
onClick = { selectedPosition.value = index }
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = remarks, style = MaterialTheme.typography.bodyLarge)
Text(text = item.label, style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui.subscription
import android.os.Bundle
import android.text.TextUtils
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -30,7 +29,6 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
@@ -79,26 +77,19 @@ class SubEditActivity : BaseComponentActivity() {
}
private fun saveServer(subItem: SubscriptionItem): Boolean {
if (TextUtils.isEmpty(subItem.remarks)) {
toast(R.string.sub_setting_remarks)
return false
}
if (subItem.url.isNotEmpty()) {
if (!Utils.isValidUrl(subItem.url)) {
toast(R.string.toast_invalid_url)
return false
}
if (!Utils.isValidSubUrl(subItem.url)) {
toast(R.string.toast_insecure_url_protocol)
if (!subItem.allowInsecureUrl) {
return false
}
if (!Utils.isValidSubUrl(subItem.url) && !subItem.allowInsecureUrl) {
return false
}
}
if (subItem.autoUpdate && subItem.updateInterval < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
toast(R.string.toast_invalid_update_interval)
return false
}
@@ -133,13 +124,16 @@ fun SubEditScreen(
) {
//val context = LocalContext.current
var remarks by rememberSaveable { mutableStateOf(initial.remarks.orEmpty()) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var url by rememberSaveable { mutableStateOf(initial.url.orEmpty()) }
var isUrlError by rememberSaveable { mutableStateOf(false) }
var userAgent by rememberSaveable { mutableStateOf(initial.userAgent.orEmpty()) }
var requestHeaders by rememberSaveable { mutableStateOf(initial.requestHeaders.orEmpty()) }
var filter by rememberSaveable { mutableStateOf(initial.filter ?: "") }
var enabled by rememberSaveable { mutableStateOf(initial.enabled) }
var autoUpdate by rememberSaveable { mutableStateOf(initial.autoUpdate) }
var updateInterval by rememberSaveable { mutableStateOf(initial.updateInterval.toString()) }
var isUpdateIntervalError by rememberSaveable { mutableStateOf(false) }
var allowInsecureUrl by rememberSaveable { mutableStateOf(initial.allowInsecureUrl) }
var prevProfile by rememberSaveable { mutableStateOf(initial.prevProfile ?: "") }
var nextProfile by rememberSaveable { mutableStateOf(initial.nextProfile ?: "") }
@@ -178,7 +172,22 @@ fun SubEditScreen(
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.acc_delete))
}
}
IconButton(onClick = { buildSubItem()?.let { onSave(it) } }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
val urlErr = url.isNotEmpty() && (
!Utils.isValidUrl(url) || (!Utils.isValidSubUrl(url) && !allowInsecureUrl)
)
val intervalErr = autoUpdate && updateInterval.toLongEx() < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES
isRemarksError = remarksErr
isUrlError = urlErr
isUpdateIntervalError = intervalErr
val hasError = remarksErr || urlErr || intervalErr
if (!hasError) {
onSave(buildSubItem())
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
}
@@ -196,8 +205,19 @@ fun SubEditScreen(
.padding(vertical = 8.dp)
.padding(bottom = 36.dp)
) {
FormTextField(stringResource(R.string.sub_setting_remarks), remarks, { remarks = it })
FormTextField(stringResource(R.string.sub_setting_url), url, { url = it })
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it },
isError = isRemarksError
)
FormTextField(
label = stringResource(R.string.sub_setting_url),
value = url,
onValueChange = { url = it },
isError = isUrlError,
supportingText = if (isUrlError) stringResource(R.string.toast_invalid_url) else null
)
FormTextField(stringResource(R.string.sub_setting_user_agent), userAgent, { userAgent = it })
FormTextField(stringResource(R.string.sub_setting_request_headers), requestHeaders, { requestHeaders = it })
FormTextField(stringResource(R.string.sub_setting_filter), filter, { filter = it })
@@ -214,8 +234,12 @@ fun SubEditScreen(
)
FormTextField(
stringResource(R.string.title_pref_auto_update_interval),
updateInterval, { updateInterval = it }, keyboardType = KeyboardType.Number
label = stringResource(R.string.title_pref_auto_update_interval),
value = updateInterval,
onValueChange = { updateInterval = it },
keyboardType = KeyboardType.Number,
isError = isUpdateIntervalError,
supportingText = if (isUpdateIntervalError) stringResource(R.string.toast_invalid_update_interval) else null
)
SettingsSwitchItem(
@@ -248,6 +272,7 @@ fun SubEditScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_subscription_group),
itemName = initial.remarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui.subscription
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import androidx.activity.viewModels
import androidx.annotation.StringRes
@@ -47,14 +46,12 @@ import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
import com.v2ray.ang.ui.compose.ItemDivider
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.QRCodeDialog
import com.v2ray.ang.ui.compose.ReorderableListItem
import com.v2ray.ang.ui.compose.SelectListDialog
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.colorFabActive
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -64,6 +61,8 @@ private enum class SubscriptionShareAction(@StringRes val labelRes: Int) {
Clipboard(R.string.share_subscription_clipboard)
}
private data class SubscriptionDeleteTarget(val guid: String, val name: String)
class SubSettingActivity : BaseComponentActivity() {
private val viewModel: SubscriptionsViewModel by viewModels()
@@ -84,7 +83,7 @@ class SubSettingActivity : BaseComponentActivity() {
startActivity(Intent(this, SubEditActivity::class.java).putExtra("subId", subId))
},
onRemoveSub = { subId -> removeSub(subId) },
onShareQRCode = { url -> QRCodeDecoder.createQRCode(url) },
onShareQRCode = viewModel::shareQRCode,
onShareClipboard = { url ->
Utils.setClipboard(this, url)
toast(getString(R.string.toast_success))
@@ -111,16 +110,16 @@ fun SubSettingScreen(
onSubUpdate: () -> Unit,
onEditSub: (String) -> Unit,
onRemoveSub: (String) -> Unit,
onShareQRCode: (String) -> Bitmap?,
onShareQRCode: (String) -> Unit,
onShareClipboard: (String) -> Unit
) {
val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle()
var showUpdateDialog by remember { mutableStateOf(false) }
var removeTarget by remember { mutableStateOf<String?>(null) }
var removeTarget by remember { mutableStateOf<SubscriptionDeleteTarget?>(null) }
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
var shareTarget by remember { mutableStateOf<Pair<String, String>?>(null) }
var showQRCodeBitmap by remember { mutableStateOf<Bitmap?>(null) }
val qrCodeBitmap by viewModel.qrCode.collectAsStateWithLifecycle()
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
@@ -215,7 +214,7 @@ fun SubSettingScreen(
)
}
IconButton(onClick = {
if (confirmRemove) removeTarget = subCache.guid
if (confirmRemove) removeTarget = SubscriptionDeleteTarget(subCache.guid, subCache.subscription.remarks)
else onRemoveSub(subCache.guid)
}) {
Icon(
@@ -255,7 +254,7 @@ fun SubSettingScreen(
onSelected = { action ->
shareTarget = null
when (action) {
SubscriptionShareAction.QRCode -> showQRCodeBitmap = onShareQRCode(url)
SubscriptionShareAction.QRCode -> onShareQRCode(url)
SubscriptionShareAction.Clipboard -> onShareClipboard(url)
}
},
@@ -264,19 +263,20 @@ fun SubSettingScreen(
}
// QR Code Dialog
if (showQRCodeBitmap != null) {
if (qrCodeBitmap != null) {
QRCodeDialog(
bitmap = showQRCodeBitmap,
onDismiss = { showQRCodeBitmap = null }
bitmap = qrCodeBitmap,
onDismiss = viewModel::dismissQRCode
)
}
if (removeTarget != null) {
removeTarget?.let { target ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_subscription_group),
itemName = target.name,
onConfirm = {
onRemoveSub(removeTarget!!)
removeTarget = null
onRemoveSub(target.guid)
},
onDismiss = { removeTarget = null }
)
@@ -1,6 +1,8 @@
package com.v2ray.ang.ui.subscription
import android.app.Application
import android.graphics.Bitmap
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.SubscriptionUpdateMessage
@@ -14,14 +16,20 @@ import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SubscriptionsViewModel(application: Application) : BaseViewModel(application) {
private var qrCodeJob: Job? = null
private val _qrCode = MutableStateFlow<Bitmap?>(null)
internal val qrCode = _qrCode.asStateFlow()
private val subscriptions: MutableList<SubscriptionCache> =
MmkvManager.decodeSubscriptions().toMutableList()
@@ -55,6 +63,21 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
_subsFlow.value = subscriptions.toList()
}
internal fun shareQRCode(url: String) {
dismissQRCode()
qrCodeJob = viewModelScope.launch {
val bitmap = withContext(Dispatchers.Default) { QRCodeDecoder.createQRCode(url) }
if (bitmap == null) toastError(R.string.toast_failure)
_qrCode.value = bitmap
}
}
internal fun dismissQRCode() {
qrCodeJob?.cancel()
qrCodeJob = null
_qrCode.value = null
}
fun move(fromPosition: Int, toPosition: Int) {
if (subscriptions.moveItem(fromPosition, toPosition)) {
MmkvManager.encodeSubsList(subscriptions.mapTo(mutableListOf()) { it.guid })
@@ -87,7 +110,13 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
toast(R.string.title_update_subscription_no_subscription)
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
toast(getString(R.string.title_update_config_count, result.configCount))
toast(
getQuantityString(
R.plurals.title_update_config_count,
result.configCount,
result.configCount,
)
)
else ->
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
@@ -4,6 +4,8 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.text.format.DateFormat
import android.text.format.Formatter
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
@@ -34,6 +36,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -45,7 +49,6 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
@@ -56,8 +59,8 @@ import com.v2ray.ang.ui.compose.AppDropdownMenuItems
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
import com.v2ray.ang.ui.compose.ItemDivider
import com.v2ray.ang.ui.compose.SettingsListItem
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.SettingsListItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
@@ -66,7 +69,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
private enum class AddAssetMenuAction(@StringRes val labelRes: Int) {
@@ -219,7 +222,13 @@ class UserAssetActivity : HelperBaseComponentActivity() {
viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
}
if (result.successCount > 0) {
toast(getString(R.string.title_update_asset_count, result.successCount))
toast(
resources.getQuantityString(
R.plurals.title_update_asset_count,
result.successCount,
result.successCount,
)
)
} else {
toast(getString(R.string.toast_failure))
}
@@ -336,7 +345,8 @@ internal fun UserAssetScreen(
deleteTarget?.let { asset ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_asset_file, asset.name),
message = stringResource(R.string.confirm_delete_asset_file),
itemName = asset.name,
onConfirm = {
deleteTarget = null
onRemoveAsset(asset.guid, asset.name)
@@ -353,11 +363,14 @@ private fun UserAssetItem(
onEdit: () -> Unit,
onDeleteClick: () -> Unit
) {
val context = LocalContext.current
val locale = LocalConfiguration.current.locales[0]
val propertiesText = if (fileMetadata != null) {
remember(fileMetadata) {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
"${fileMetadata.length.toTrafficString()} • ${dateFormat.format(Date(fileMetadata.lastModified))}"
}
val fileSize = Formatter.formatFileSize(context, fileMetadata.length)
val skeleton = if (DateFormat.is24HourFormat(context)) "yMMMdHm" else "yMMMdhm"
val formattedDate = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
.format(Date(fileMetadata.lastModified))
"$fileSize • $formattedDate"
} else {
stringResource(R.string.msg_file_not_found)
}
@@ -3,17 +3,20 @@ package com.v2ray.ang.ui.userasset
import android.os.Bundle
import android.text.TextUtils
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -82,6 +85,15 @@ class UserAssetUrlActivity : BaseComponentActivity() {
}
private fun saveServer(remarks: String, url: String): Boolean {
val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.assetUrl.remarks == remarks && it.guid != editAssetId }) {
toast(R.string.msg_remark_is_duplicate)
return false
}
if (TextUtils.isEmpty(remarks) || TextUtils.isEmpty(url)) {
return false
}
var assetItem = MmkvManager.decodeAsset(editAssetId)
var assetId = editAssetId
if (assetItem != null) {
@@ -101,20 +113,6 @@ class UserAssetUrlActivity : BaseComponentActivity() {
assetItem.remarks = remarks
assetItem.url = url
val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
toast(R.string.msg_remark_is_duplicate)
return false
}
if (TextUtils.isEmpty(assetItem.remarks)) {
toast(R.string.sub_setting_remarks)
return false
}
if (TextUtils.isEmpty(assetItem.url)) {
toast(R.string.title_url)
return false
}
MmkvManager.encodeAsset(assetId, assetItem)
toastSuccess(R.string.toast_success)
finish()
@@ -139,9 +137,11 @@ fun UserAssetUrlScreen(
onSave: (String, String) -> Boolean,
onDelete: () -> Unit
) {
var remarks by remember { mutableStateOf(initialRemarks) }
var url by remember { mutableStateOf(initialUrl) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var remarks by rememberSaveable(editAssetId, initialRemarks) { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable(editAssetId) { mutableStateOf(false) }
var url by rememberSaveable(editAssetId, initialUrl) { mutableStateOf(initialUrl) }
var isUrlError by rememberSaveable(editAssetId) { mutableStateOf(false) }
var showDeleteConfirm by rememberSaveable(editAssetId) { mutableStateOf(false) }
Scaffold(
contentWindowInsets = WindowInsets(0),
@@ -158,7 +158,18 @@ fun UserAssetUrlScreen(
)
}
}
IconButton(onClick = { onSave(remarks, url) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
val urlErr = url.isBlank()
isRemarksError = remarksErr
isUrlError = urlErr
val hasError = remarksErr || urlErr
if (!hasError) {
onSave(remarks, url)
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -172,17 +183,22 @@ fun UserAssetUrlScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp)
) {
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
FormTextField(
label = stringResource(R.string.title_url),
value = url,
onValueChange = { url = it }
onValueChange = { url = it },
isError = isUrlError
)
NavigationBarsSpacer()
}
@@ -191,6 +207,7 @@ fun UserAssetUrlScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_asset_source),
itemName = initialRemarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more