Compare commits

..
94 Commits
Author SHA1 Message Date
2dust 200c59f773 up 2.3.8 2026-09-10 14:23:35 +08:00
eliotcougar 49835a5cdb Use completed-action wording and plurals for update results (#6196)
Extract the config and asset update-result resources from local/talkback-all-10 in all nine supported locales. Describe completed updates instead of an update command, and select singular or plural forms using the actual config or successful asset count.

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

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

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

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

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

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

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

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

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

* Resize the asset editor when the keyboard opens

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

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

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

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

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

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

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

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

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

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

* fix: tune backup ZIP limits

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

* docs: add agent harness adapters

* Update local-only block markers in AGENTS.md

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

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

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

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

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

* Update CoreServiceManager.kt

---------

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

* Extract NavigationBarsSpacer and navigationBarsBottomPadding to reduce duplicate code

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

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

* Fix Compose resource lookup in routing editor

* Adjust string

---------

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

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

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

* Clarify profile export actions

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

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

* fix: translate BQI core errors

* fix: correct Bakhtiari orthography and wording

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

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

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

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

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

* fix: keep protocol description technical

* fix: keep Logcat diagnostics unlocalized

* getRemoteIPInfo

---------

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

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

* Remove enabled: Boolean = true

---------

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

* fix: finish locale translations

* fix: align routeOnly terminology

* fix: preserve core terminology in translations

* fix: refine localization wording

* fix: finish localization resource cleanup

* fix: standardize URL capitalization

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

* Use Material 3 navigation drawer components

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 20:30:35 +08:00
eliotcougar 320fb6a10b Handle padded camera rows in QR scanner (#6012)
Camera HALs may expose a Y plane whose row stride is wider than the visible image. Pass that stride to ZXing so padded rows remain aligned, and cover the vendor-layout case with a valid VLESS QR regression test.
2026-08-04 20:30:25 +08:00
2dust 559a00e66e Fix cancel signaling in measure/ping services 2026-08-03 20:48:37 +08:00
eliotcougar 31dfaeed6c Add cancel action to batch-test notification (#6009) 2026-08-03 20:42:04 +08:00
2dust 19b0c9a2c3 up 2.3.2 2026-08-02 15:17:48 +08:00
2dust db0755cf0c Auto delete and sort subscription after core tests 2026-08-02 15:14:40 +08:00
mola dc6a7eae93 fix: hide scrollbars during activity transitions (#6003) 2026-08-02 14:57:22 +08:00
2dust 3ae8db972e Fix ipv6 hosts
https://github.com/2dust/v2rayNG/issues/5965
2026-08-02 14:53:57 +08:00
2dust a899d7f444 Automated testing takes a long time. If enabled, the task will run in the background; otherwise, it will run on the current page.
https://github.com/2dust/v2rayNG/issues/5999
2026-08-02 14:42:00 +08:00
dkgksandroot c8f78621bf fix: restore XHTTP/gRPC/KCP transport settings in Compose server UI (#5995)
The Compose UI migration (ServerUiState + BaseServerActivity) dropped or
misbound several transport-specific fields that the legacy editor handled
correctly:

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

Changes (2 files):

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

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

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

Fixes #5993

Signed-off-by: dkgks

Signed-off-by: dkgks
Co-authored-by: root <root@debianG>
2026-08-02 10:41:49 +08:00
vemneyy 0cf95ac8a3 fix: tunnel dying some time after a network handover (#5994) 2026-08-02 10:35:36 +08:00
2dust 83abd61a7b up 2.3.1 2026-08-01 15:20:00 +08:00
2dust 4dc3a20a5c Update TProxy JNI signatures with return types 2026-08-01 15:19:30 +08:00
150 changed files with 7115 additions and 3524 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.6.0
uses: actions/setup-java@v6.0.0
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
+19 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 740
versionName = "2.3.0"
versionCode = 748
versionName = "2.3.8"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
@@ -128,6 +128,21 @@ android {
compose = true
}
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"en",
"zh-rCN",
"zh-rTW",
"vi",
"ru",
"fa",
"ar",
"bn",
"bqi-rIR"
)
}
packaging {
jniLibs {
useLegacyPackaging = true
@@ -142,6 +157,7 @@ dependencies {
// AndroidX Core Libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
// Compose Libraries
implementation(platform(libs.androidx.compose.bom))
@@ -153,6 +169,7 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
+7 -7
View File
@@ -33,10 +33,10 @@
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_start_24dp"
android:shortcutDisabledMessage="@string/toast_services_start"
android:shortcutDisabledMessage="@string/acc_start"
android:shortcutId="shortcuts_start"
android:shortcutLongLabel="@string/toast_services_start"
android:shortcutShortLabel="@string/toast_services_start">
android:shortcutLongLabel="@string/acc_start"
android:shortcutShortLabel="@string/acc_start">
<!--suppress AndroidDomInspection -->
<intent
@@ -48,10 +48,10 @@
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_stop_24dp"
android:shortcutDisabledMessage="@string/toast_services_stop"
android:shortcutDisabledMessage="@string/acc_stop"
android:shortcutId="shortcuts_stop"
android:shortcutLongLabel="@string/toast_services_stop"
android:shortcutShortLabel="@string/toast_services_stop">
android:shortcutLongLabel="@string/acc_stop"
android:shortcutShortLabel="@string/acc_stop">
<!--suppress AndroidDomInspection -->
<intent
@@ -60,4 +60,4 @@
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>
</shortcuts>
+24 -11
View File
@@ -52,6 +52,15 @@
android:theme="@style/AppThemeDayNight.NoActionBar"
android:usesCleartextTraffic="true">
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<activity
android:name=".ui.main.MainActivity"
android:exported="true"
@@ -144,6 +153,7 @@
android:exported="false" />
<activity
android:name=".ui.userasset.UserAssetUrlActivity"
android:windowSoftInputMode="stateUnchanged|adjustResize"
android:exported="false" />
<activity
@@ -157,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
@@ -200,6 +210,9 @@
<activity
android:name=".ui.AboutActivity"
android:exported="false" />
<activity
android:name=".ui.TranslatorsActivity"
android:exported="false" />
<service
android:name=".service.CoreVpnService"
@@ -208,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" />
@@ -226,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" />
@@ -237,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" />
@@ -247,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" />
@@ -257,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" />
@@ -266,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" />
@@ -294,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>
@@ -315,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" }
]
}
]
@@ -76,7 +76,11 @@
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP"
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
}
}
},
"tag": "direct"
@@ -93,7 +93,11 @@
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP"
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
}
}
},
"tag": "direct"
@@ -2,10 +2,12 @@ package com.v2ray.ang
import android.app.Application
import android.content.Context
import androidx.core.content.ContextCompat
import androidx.work.Configuration
import androidx.work.WorkManager
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.compose.ThemeManager
@@ -19,7 +21,7 @@ class AngApplication : Application() {
* @param base The base context.
*/
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
super.attachBaseContext(base?.let(ContextCompat::getContextForLanguage))
application = this
}
@@ -33,7 +35,9 @@ class AngApplication : Application() {
override fun onCreate() {
super.onCreate()
MMKV.initialize(this)
MmkvManager.initialize(this)
AppLocaleManager.initialize(this)
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
@@ -56,7 +56,9 @@ object AppConfig {
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
const val PREF_LANGUAGE = "pref_language"
const val PREF_APP_LOCALE_MIGRATED = "pref_app_locale_migrated"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_DYNAMIC_COLOR = "pref_dynamic_color"
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
@@ -166,6 +168,13 @@ object AppConfig {
const val WIREGUARD_LOCAL_MTU = "1420"
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
@@ -178,7 +187,7 @@ object AppConfig {
const val MSG_STATE_STOP_SUCCESS = 41
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
const val MSG_MEASURE_DELAY_SUCCESS = 61
const val MSG_MEASURE_DELAY_RESULT = 61
const val MSG_MEASURE_CONFIG_START = 7
const val MSG_MEASURE_CONFIG_CANCEL = 71
const val MSG_MEASURE_CONFIG_SUCCESS = 72
@@ -3,6 +3,7 @@ package com.v2ray.ang.core
import android.content.Context
import android.text.TextUtils
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.dto.CoreConfigContext
@@ -33,7 +34,11 @@ object CoreConfigManager {
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
try {
val configContext = CoreConfigContextBuilder.build(context, guid)
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
?: return ConfigResult(
status = false,
guid = guid,
errorMessage = "Failed to build config context"
)
if (configContext.isCustom) {
return buildV2rayCustomConfig(configContext)
}
@@ -56,7 +61,11 @@ object CoreConfigManager {
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
try {
val configContext = CoreConfigContextBuilder.build(context, guid)
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
?: return ConfigResult(
status = false,
guid = guid,
errorMessage = "Failed to build config context"
)
if (configContext.isCustom) {
return buildV2rayCustomConfig(configContext)
}
@@ -69,7 +78,7 @@ object CoreConfigManager {
return ConfigResult(
status = false,
guid = guid,
errorMessage = "Failed to get V2ray config for speedtest: ${e.message ?: e.javaClass.simpleName}"
errorMessage = "Failed to get V2ray config: ${e.message ?: e.javaClass.simpleName}"
)
}
}
@@ -80,14 +89,43 @@ object CoreConfigManager {
private fun buildV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
val context = configContext.context
val raw = MmkvManager.decodeServerRaw(configContext.guid)
?: return ConfigResult(status = false, guid = configContext.guid, errorMessage = "Custom config is empty")
?: return ConfigResult(
status = false,
guid = configContext.guid,
errorMessage = "Failed to build config context, config is empty"
)
val result = ConfigResult(true, configContext.guid, raw)
if (!needTun()) {
return result
}
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
// Inject or remove traffic statistics configuration based on user preference
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) == true) {
if (!json.has("stats")) {
json.add("stats", JsonObject())
}
if (!json.has("policy")) {
val policyObj = JsonObject()
val systemObj = JsonObject()
systemObj.addProperty("statsOutboundUplink", true)
systemObj.addProperty("statsOutboundDownlink", true)
policyObj.add("system", systemObj)
json.add("policy", policyObj)
}
} else {
json.remove("stats")
// 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()) {
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, configContext.guid, it) } ?: result
}
// Check whether package names need to be replaced with UIDs
if (SettingsManager.canUseProcessRouting()) {
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
@@ -479,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(
@@ -677,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
}
}
@@ -915,9 +953,14 @@ object CoreConfigManager {
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
if (userHosts.isNotNullEmpty()) {
val userHostsMap = userHosts?.split(",").orEmpty()
.filter { it.isNotEmpty() }
.filter { it.contains(":") }
.associate { it.split(":").let { (k, v) -> k to v } }
.filter { it.isNotBlank() && it.contains(":") }
.associate {
// Use limit = 2 to split only at the first colon.
// This ensures that IPv6 addresses (which contain multiple colons)
// are preserved entirely in the second part.
val parts = it.split(":", limit = 2)
parts[0].trim() to parts[1].trim()
}
hosts.putAll(userHostsMap)
}
@@ -1015,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
@@ -396,6 +396,7 @@ object CoreOutboundBuilder {
)
)
}
udpMaskList.reverse()
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
udp = udpMaskList.toList()
)
@@ -635,8 +636,7 @@ object CoreOutboundBuilder {
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
} ?: JsonObject()
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
fun appendMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
val current = finalMaskObj.get(scope)
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
return
@@ -651,8 +651,8 @@ object CoreOutboundBuilder {
finalMaskObj.add(scope, newArray)
}
prependMask("tcp", fragmentMask)
prependMask("udp", noiseMask)
appendMask("tcp", fragmentMask)
appendMask("udp", noiseMask)
streamSettings.finalmask = finalMaskObj
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
@@ -667,7 +667,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
@@ -1,5 +1,6 @@
package com.v2ray.ang.core
import android.app.Activity
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
@@ -11,12 +12,13 @@ import android.os.ParcelFileDescriptor
import android.system.OsConstants
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.IDialerService
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.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
@@ -31,7 +33,6 @@ import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.jvm.Volatile
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.ProcessFinder
@@ -250,7 +251,7 @@ object CoreServiceManager {
if (!isRunning()) return false
return try {
val tunFd = tunFdForCore()
val tunFd = currentVpnInterface
isReloading = true
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
@@ -270,23 +271,6 @@ object CoreServiceManager {
}
}
/**
* Returns the tun descriptor to hand to the core on a reload.
*
* With hev-socks5-tunnel the core is started without a tun and never touches the descriptor.
* Otherwise it closes the one it was given when it stops, which would take the VPN interface
* down, so it gets a duplicate to close instead.
*/
private fun tunFdForCore(): ParcelFileDescriptor? {
val vpnInterface = currentVpnInterface ?: return null
return try {
if (SettingsManager.isUsingHevTun()) vpnInterface else vpnInterface.dup()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to duplicate VPN interface", e)
throw e
}
}
/**
* Queries and resets all outbound traffic counters in one core call.
* Go side format: tag,direction,value;tag,direction,value;
@@ -338,30 +322,25 @@ object CoreServiceManager {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
errorStr = e.message?.substringAfter("\":").orEmpty()
}
if (time == -1L) {
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
errorStr = e.message?.substringAfter("\":").orEmpty()
}
}
val result = if (time >= 0) {
service.getString(R.string.connection_test_available, time)
} else {
service.getString(R.string.connection_test_error, errorStr)
}
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
// Only fetch IP info if the delay test was successful
if (time >= 0) {
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
}
}
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)
}
}
@@ -383,6 +362,7 @@ object CoreServiceManager {
* @return 0 for success, any other value for failure.
*/
override fun startup(): Long {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback startup")
return 0
}
@@ -391,14 +371,8 @@ object CoreServiceManager {
* @return 0 for success, any other value for failure.
*/
override fun shutdown(): Long {
val serviceControl = serviceControl?.get() ?: return -1
return try {
serviceControl.stopService()
0
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
-1
}
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback shutdown")
return 0
}
/**
@@ -408,6 +382,7 @@ object CoreServiceManager {
* @return Always returns 0.
*/
override fun onEmitStatus(l: Long, s: String?): Long {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback onEmitStatus $s")
return 0
}
}
@@ -486,9 +461,20 @@ object CoreServiceManager {
AppConfig.MSG_STATE_RESTART -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService()
Thread.sleep(500L)
LauncherManager.startService(serviceControl.getService())
// The UI and daemon run in separate processes, so acknowledge the active
// daemon before stopping it instead of relying on possibly stale UI state.
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
val pendingResult = goAsync()
CoroutineScope(Dispatchers.Default).launch {
try {
serviceControl.stopService()
delay(500L)
LauncherManager.startService(serviceControl.getService())
} finally {
pendingResult.finish()
}
}
}
AppConfig.MSG_MEASURE_DELAY -> {
@@ -509,4 +495,4 @@ object CoreServiceManager {
}
}
}
}
}
@@ -56,6 +56,18 @@ object LauncherManager {
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
}
/** Restarts the active daemon without starting a stopped service. */
fun restartService(context: Context) {
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_RESTART, "")
}
/** Restarts the active daemon, or delegates to the caller's permission-aware start flow. */
fun restartServiceOrStart(context: Context, startIfStopped: () -> Unit) {
MessageHelper.sendMsg2ServiceForResult(context, AppConfig.MSG_STATE_RESTART, "") { handled ->
if (!handled) startIfStopped()
}
}
@Throws(Exception::class)
private fun startContextService(context: Context) {
// Note: isRunning check is removed here to avoid loading Native libraries in the UI process.
@@ -1,11 +1,8 @@
package com.v2ray.ang.dto
import android.graphics.drawable.Drawable
data class AppInfo(
val appName: String,
val packageName: String,
val appIcon: Drawable,
val isSystemApp: Boolean,
var isSelected: Int
)
@@ -0,0 +1,11 @@
package com.v2ray.ang.dto
import java.io.Serializable
/** Locale-neutral result sent from the daemon for presentation by the UI process. */
data class ConnectionTestResult(
val delayMillis: Long,
val errorMessage: String = "",
val country: String? = null,
val ipAddress: String? = null,
) : Serializable
@@ -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,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()
}
}
}
@@ -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?,
@@ -1,10 +1,3 @@
package com.v2ray.ang.dto.entities
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
fun getTestDelayString(): String {
if (testDelayMillis == 0L) {
return ""
}
return testDelayMillis.toString() + "ms"
}
}
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L)
@@ -4,5 +4,4 @@ data class ServersCache(
val guid: String,
val profile: ProfileItem,
val testDelayMillis: Long = 0L,
val testDelayString: String = "",
)
@@ -1,20 +1,57 @@
package com.v2ray.ang.enums
enum class Language(val code: String) {
AUTO("auto"),
ENGLISH("en"),
CHINA("zh-rCN"),
TRADITIONAL_CHINESE("zh-rTW"),
VIETNAMESE("vi"),
RUSSIAN("ru"),
PERSIAN("fa"),
ARABIC("ar"),
BANGLA("bn"),
BAKHTIARI("bqi-rIR");
import androidx.core.os.LocaleListCompat
import java.util.Locale
enum class Language(val code: String, val languageTag: String) {
AUTO("auto", ""),
ENGLISH("en", "en"),
CHINA("zh-rCN", "zh-CN"),
TRADITIONAL_CHINESE("zh-rTW", "zh-TW"),
VIETNAMESE("vi", "vi"),
RUSSIAN("ru", "ru"),
PERSIAN("fa", "fa"),
ARABIC("ar", "ar"),
BANGLA("bn", "bn"),
BAKHTIARI("bqi-rIR", "bqi-IR");
val locale: Locale?
get() = languageTag.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag)
fun toLocaleList(): LocaleListCompat =
if (languageTag.isEmpty()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(languageTag)
}
companion object {
fun fromCode(code: String): Language {
return entries.find { it.code == code } ?: AUTO
}
fun fromLanguageTag(languageTag: String?): Language {
if (languageTag.isNullOrBlank()) return AUTO
val locale = Locale.forLanguageTag(languageTag)
return when (locale.language) {
"en" -> ENGLISH
"zh" -> if (
locale.script == "Hant" || locale.country in setOf("TW", "HK", "MO")
) {
TRADITIONAL_CHINESE
} else {
CHINA
}
"vi" -> VIETNAMESE
"ru" -> RUSSIAN
"fa" -> PERSIAN
"ar" -> ARABIC
"bn" -> BANGLA
"bqi" -> BAKHTIARI
else -> AUTO
}
}
}
}
@@ -3,6 +3,8 @@ package com.v2ray.ang.enums
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import com.v2ray.ang.R
/**
* Permission types used in the app, handling API level differences.
@@ -28,12 +30,13 @@ enum class PermissionType {
/** Return the actual Android permission string */
abstract fun getPermission(): String
/** Return a human-readable label for the permission */
fun getLabel(): String {
/** Return the string resource for the human-readable permission label. */
@StringRes
fun getLabelRes(): Int {
return when (this) {
CAMERA -> "Camera"
POST_NOTIFICATIONS -> "Notification"
ACCESS_LOCAL_NETWORK -> "Local Network"
CAMERA -> R.string.permission_camera
POST_NOTIFICATIONS -> R.string.permission_notification
ACCESS_LOCAL_NETWORK -> R.string.permission_local_network
}
}
}
@@ -5,18 +5,5 @@ enum class RoutingType(val fileName: String) {
BLACK("custom_routing_black"),
GLOBAL("custom_routing_global"),
WHITE_IRAN("custom_routing_white_iran"),
WHITE_RUSSIA("custom_routing_white_russia");
companion object {
fun fromIndex(index: Int): RoutingType {
return when (index) {
0 -> WHITE
1 -> BLACK
2 -> GLOBAL
3 -> WHITE_IRAN
4 -> WHITE_RUSSIA
else -> WHITE
}
}
}
WHITE_RUSSIA("custom_routing_white_russia")
}
@@ -7,6 +7,7 @@ import com.v2ray.ang.enums.EConfigType
import java.io.Serializable
import java.net.URI
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
const val THRESHOLD = 1000L
const val DIVISOR = 1024.0
@@ -76,3 +77,19 @@ fun EConfigType.isGroupType(): Boolean {
fun EConfigType.isComplexType(): Boolean {
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
/**
* Shorthand for delay with Int milliseconds using Duration to avoid legacy Long overload warning.
*/
suspend fun delay(millis: Int) {
kotlinx.coroutines.delay(millis.toLong().milliseconds)
}
/**
* Shorthand for delay with Long milliseconds using Duration to avoid legacy Long overload warning.
*/
suspend fun delay(millis: Long) {
kotlinx.coroutines.delay(millis.milliseconds)
}
@@ -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
}
}
@@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.Bitmap
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.UrlContentRequest
@@ -31,6 +30,11 @@ import java.net.URI
object AngConfigManager {
private data class ParsedProfile(
val profile: ProfileItem,
val rawConfig: String? = null,
)
// Parser mapping for different config types (lazy initialized)
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
mapOf(
@@ -44,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
)
}
@@ -177,23 +180,28 @@ object AngConfigManager {
* @return A pair containing the number of configurations and subscriptions imported.
*/
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
var count = parseBatchConfig(Utils.decode(server), subid, append)
if (count <= 0) {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid, append)
}
return try {
var count = parseBatchConfig(Utils.decode(server), subid, append)
if (count <= 0) {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid, append)
}
var countSub = parseBatchSubscription(server)
if (countSub <= 0) {
countSub = parseBatchSubscription(Utils.decode(server))
}
if (countSub > 0) {
updateConfigViaSubAll()
}
var countSub = parseBatchSubscription(server)
if (countSub <= 0) {
countSub = parseBatchSubscription(Utils.decode(server))
}
if (countSub > 0) {
updateConfigViaSubAll()
}
return count to countSub
count to countSub
} catch (e: ProfileStorageException) {
LogUtil.e(AppConfig.TAG, "Failed to store imported profiles", e)
0 to 0
}
}
/**
@@ -236,34 +244,40 @@ object AngConfigManager {
if (servers == null) {
return 0
}
// Find the currently selected server that belongs to the same subscription before replacement.
val removedSelected = getRemovedSelectedProfile(subid, append)
val subItem = MmkvManager.decodeSubscription(subid)
// Parse all configs first (no I/O during parsing)
val configs = mutableListOf<ProfileItem>()
val v2raynLines = mutableListOf<String>()
servers.lines()
.distinct()
.reversed()
.forEach {
val config = parseConfig(it, subid, subItem)
if (config != null) {
configs.add(config)
if (it.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)) {
v2raynLines.add(it)
} else {
val config = parseConfig(it, subid, subItem)
if (config != null) {
configs.add(config)
}
}
}
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
val v2raynConfigs = V2rayNFmt.parse(v2raynLines, subid)
val allConfigs = v2raynConfigs + configs
if (allConfigs.isNotEmpty()) {
commitProfiles(
configs = allConfigs.map(::ParsedProfile),
subid = subid,
append = append,
)
}
return configs.size
return allConfigs.size
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
}
@@ -271,112 +285,32 @@ object AngConfigManager {
}
/**
* Batch save configurations to reduce serverList read/write operations.
* Reads serverList once, saves all configs, then writes serverList once.
* Commits parsed profiles before removing the profiles they replace.
*
* @param configs The list of ProfileItem to save.
* @param configs The parsed profiles to save.
* @param subid The subscription ID.
* @return Map of generated keys to their corresponding ProfileItem.
* @param append Whether to append to the existing server list.
*/
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
val keyToProfile = mutableMapOf<String, ProfileItem>()
private fun commitProfiles(
configs: List<ParsedProfile>,
subid: String,
append: Boolean,
) {
val keyToProfile = linkedMapOf<String, ProfileItem>()
val rawConfigs = mutableMapOf<String, String>()
// Read serverList once
val serverList = MmkvManager.decodeServerList(subid)
configs.forEach { config ->
configs.forEach { parsed ->
val key = Utils.getUuid()
// Save profile directly without updating serverList
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
if (!serverList.contains(key)) {
serverList.add(0, key)
}
keyToProfile[key] = config
keyToProfile[key] = parsed.profile
parsed.rawConfig?.let { raw -> rawConfigs[key] = raw }
}
// Write serverList once
MmkvManager.encodeServerList(serverList, subid)
return keyToProfile
}
/**
* Finds a matched profile key from the given key-profile map using multi-level matching.
* Matching priority (from highest to lowest):
* 1. Exact match: server + port + password
* 2. Match by remarks (exact match)
* 3. Match by server + port
* 4. Match by server only
*
* @param keyToProfile Map of server keys to their ProfileItem
* @param target Target profile to match
* @return Matched key or null
*/
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty()) return null
if (target == null) return null
// Level 0: Full match (remarks + server + port + password)
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks) &&
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
}
// Level 1: Match by remarks
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks)
}?.key?.let { return it }
}
// Level 2: Exact match (server + port + password)
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
// Level 3: Match by server + port
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort)
}?.key?.let { return it }
// Level 4: Match by server only
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server)
}?.key?.let { return it }
// If old selected node cannot be matched, fall back to the first imported config.
return keyToProfile.keys.firstOrNull()
}
/**
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
*/
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
if (subid.isBlank() || append) return null
return MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
}
/**
* Case-insensitive trimmed string comparison.
*
* @param left First string
* @param right Second string
* @return True if both are non-empty and equal (case-insensitive, trimmed)
*/
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
MmkvManager.saveServerProfiles(
profiles = keyToProfile,
rawConfigs = rawConfigs,
subscriptionId = subid,
append = append,
)
}
/**
@@ -400,56 +334,54 @@ object AngConfigManager {
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) {
val removedSelected = getRemovedSelectedProfile(subid, append)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
val keyToProfile = mutableMapOf<String, ProfileItem>()
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
val configs = serverList.reversed().map { srv ->
val config = CustomFmt.parse(JsonUtil.toJson(srv))
config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
keyToProfile[key] = config
count += 1
ParsedProfile(
profile = config,
rawConfig = JsonUtil.toJsonPretty(srv) ?: "",
)
}
if (count > 0) {
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
return count
commitProfiles(configs, subid, append)
return configs.size
}
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
}
try {
// For compatibility
val config = CustomFmt.parse(server) ?: return 0
val config = CustomFmt.parse(server)
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
commitProfiles(
configs = listOf(ParsedProfile(config, server)),
subid = subid,
append = append,
)
return 1
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
val config = WireguardFmt.parseWireguardConfFile(server)
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
commitProfiles(
configs = listOf(ParsedProfile(config, server)),
subid = subid,
append = append,
)
return 1
} catch (e: ProfileStorageException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
@@ -496,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)
@@ -0,0 +1,140 @@
package com.v2ray.ang.handler
import android.app.LocaleManager
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.LocaleManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.os.ConfigurationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.Language
/**
* Keeps the legacy in-app language preference synchronized with Android's per-app locale APIs.
*/
object AppLocaleManager {
/**
* Migrates the existing MMKV language preference and restores it before the first activity.
*/
fun initialize(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Api33.prepareMigration(context)
} else {
initializeCompat(context)
}
}
/**
* Completes the one-time handoff after AppCompat has attached the activity context.
*/
fun onActivityCreated(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Api33.completeMigration(context)
} else {
syncLegacyPreference(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag())
}
}
/**
* Applies a language selected in the app and lets AppCompat recreate the current activity.
*/
fun setApplicationLanguage(languageCode: String) {
val language = Language.fromCode(languageCode)
persistLegacyPreference(language)
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
}
/**
* Returns a context suitable for resource access outside AppCompatActivity.
*/
fun localizedContext(context: Context): Context {
val localizedContext = ContextCompat.getContextForLanguage(context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || localizedContext !== context) {
return localizedContext
}
// During the one-time upgrade from the old picker, AppCompat has not written its locale
// record yet. Use the still-current MMKV value for non-activity contexts on that launch.
val language = storedLanguage()
language.locale ?: return context
val configuration = Configuration(context.resources.configuration)
ConfigurationCompat.setLocales(configuration, language.toLocaleList())
return context.createConfigurationContext(configuration)
}
private fun initializeCompat(context: Context) {
val storedLocales = LocaleManagerCompat.getApplicationLocales(context)
if (storedLocales.isEmpty) {
val legacyLanguage = storedLanguage()
if (legacyLanguage != Language.AUTO) {
AppCompatDelegate.setApplicationLocales(legacyLanguage.toLocaleList())
}
} else {
syncLegacyPreference(storedLocales.get(0)?.toLanguageTag())
}
}
private fun storedLanguage(): Language = Language.fromCode(
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
)
private fun syncLegacyPreference(languageTag: String?) {
persistLegacyPreference(Language.fromLanguageTag(languageTag))
}
private fun persistLegacyPreference(language: Language) {
val current = MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE)
if (current != language.code) {
MmkvManager.encodeSettings(AppConfig.PREF_LANGUAGE, language.code)
SettingsChangeManager.notifySettingChanged(AppConfig.PREF_LANGUAGE)
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private object Api33 {
fun prepareMigration(context: Context) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
return
}
val localeManager = context.getSystemService(LocaleManager::class.java)
val applicationLocales = localeManager.applicationLocales
if (!applicationLocales.isEmpty) {
syncLegacyPreference(applicationLocales.get(0)?.toLanguageTag())
return
}
val legacyLanguage = storedLanguage()
if (legacyLanguage != Language.AUTO) {
// Make application and service contexts correct even before the first activity.
localeManager.applicationLocales = android.os.LocaleList(
requireNotNull(legacyLanguage.locale)
)
}
}
fun completeMigration(context: Context) {
val localeManager = context.getSystemService(LocaleManager::class.java)
if (!MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
val frameworkLanguage = Language.fromLanguageTag(
localeManager.applicationLocales.get(0)?.toLanguageTag()
)
val language = frameworkLanguage.takeUnless { it == Language.AUTO }
?: storedLanguage()
// Android's migration guidance requires this one-time AppCompat handoff after
// Activity.onCreate(), including on Android 13 and newer.
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
persistLegacyPreference(language)
MmkvManager.encodeSettings(AppConfig.PREF_APP_LOCALE_MIGRATED, true)
return
}
syncLegacyPreference(localeManager.applicationLocales.get(0)?.toLanguageTag())
}
}
}
@@ -1,5 +1,7 @@
package com.v2ray.ang.handler
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -7,9 +9,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import com.tencent.mmkv.MMKV
import com.tencent.mmkv.MMKVHandler
import com.tencent.mmkv.MMKVLogLevel
import com.tencent.mmkv.MMKVRecoverStrategic
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.AppConfig.TAG
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.dto.entities.ProfileItem
@@ -24,6 +31,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
internal class ProfileStorageException(message: String) : IllegalStateException(message)
object MmkvManager {
//region private
@@ -41,6 +50,24 @@ object MmkvManager {
private const val KEY_SUB_IDS = "SUB_IDS"
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
private val recoveryHandler = object : MMKVHandler {
override fun onMMKVCRCCheckFail(mmapID: String) =
recoverFromStorageError(mmapID, "CRC check")
override fun onMMKVFileLengthError(mmapID: String) =
recoverFromStorageError(mmapID, "file length check")
override fun wantLogRedirecting(): Boolean = false
override fun mmkvLog(
level: MMKVLogLevel,
file: String,
line: Int,
function: String,
message: String
) = Unit
}
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
@@ -49,8 +76,83 @@ object MmkvManager {
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
private inline fun <T> withProfileIndexLock(block: () -> T): T {
return synchronized(mainStorage) {
mainStorage.lock()
try {
block()
} finally {
mainStorage.unlock()
}
}
}
private fun removeProfilePayloads(guids: Collection<String>) {
if (guids.isEmpty()) return
val keys = guids.toTypedArray()
profileFullStorage.removeValuesForKeys(keys)
serverAffStorage.removeValuesForKeys(keys)
serverRawStorage.removeValuesForKeys(keys)
}
private fun requireStorageWrite(success: Boolean, message: String) {
if (!success) throw ProfileStorageException(message)
}
private fun persistServerList(serverList: List<String>, subscriptionId: String): Boolean {
return mainStorage.encode(serverListKey(subscriptionId), JsonUtil.toJson(serverList))
}
private fun serverListKey(subscriptionId: String): String {
return "$KEY_SUB_SERVER_PREFIX${getSubscriptionId(subscriptionId)}"
}
/**
* Returns every server referenced outside the target group, or null if the raw indexes
* cannot provide a complete view.
*/
private fun decodeServersReferencedByOtherGroups(subscriptionId: String): Set<String>? {
val targetKey = serverListKey(subscriptionId)
val keys = mainStorage.allKeys() ?: return null
if (targetKey !in keys) return null
val referencedServers = mutableSetOf<String>()
for (key in keys) {
if (!key.startsWith(KEY_SUB_SERVER_PREFIX) || key == targetKey) continue
val json = mainStorage.decodeString(key)
if (json.isNullOrBlank()) return null
val serverIds = JsonUtil.fromJsonSafe(json, Array<String>::class.java) ?: return null
referencedServers.addAll(serverIds)
}
return referencedServers
}
//endregion
/**
* Initializes MMKV with best-effort recovery so a damaged store is not silently discarded.
*/
fun initialize(context: Context) {
val logLevel = if (BuildConfig.DEBUG) {
MMKVLogLevel.LevelDebug
} else {
MMKVLogLevel.LevelInfo
}
MMKV.initialize(
context,
context.filesDir.resolve("mmkv").absolutePath,
null,
logLevel,
recoveryHandler
)
}
private fun recoverFromStorageError(mmapID: String, error: String): MMKVRecoverStrategic {
Log.e(TAG, "MMKV $error failed for $mmapID; attempting data recovery")
return MMKVRecoverStrategic.OnErrorRecover
}
//region Server
/**
@@ -79,7 +181,9 @@ object MmkvManager {
* @param guid The server GUID.
*/
fun setSelectServer(guid: String) {
mainStorage.encode(KEY_SELECTED_SERVER, guid)
withProfileIndexLock {
mainStorage.encode(KEY_SELECTED_SERVER, guid)
}
}
/**
@@ -90,9 +194,9 @@ object MmkvManager {
* @param subscriptionId The subscription ID.
*/
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
mainStorage.encode(key, JsonUtil.toJson(serverList))
withProfileIndexLock {
persistServerList(serverList, subscriptionId)
}
}
@@ -105,9 +209,7 @@ object MmkvManager {
* @return The list of server GUIDs.
*/
fun decodeServerList(subscriptionId: String): MutableList<String> {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
val json = mainStorage.decodeString(key)
val json = mainStorage.decodeString(serverListKey(subscriptionId))
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
@@ -166,17 +268,28 @@ object MmkvManager {
*/
fun encodeServerConfig(guid: String, config: ProfileItem): String {
val key = guid.ifBlank { Utils.getUuid() }
profileFullStorage.encode(key, JsonUtil.toJson(config))
withProfileIndexLock {
requireStorageWrite(
profileFullStorage.encode(key, JsonUtil.toJson(config)),
"Failed to save profile payload",
)
// Use default subscription for servers without subscription
val subId = getSubscriptionId(config.subscriptionId)
val serverList = decodeServerList(subId)
// Use default subscription for servers without subscription
val subId = getSubscriptionId(config.subscriptionId)
val serverList = decodeServerList(subId)
if (!serverList.contains(key)) {
serverList.add(0, key)
encodeServerList(serverList, subId)
if (getSelectServer().isNullOrBlank()) {
mainStorage.encode(KEY_SELECTED_SERVER, key)
if (!serverList.contains(key)) {
serverList.add(0, key)
requireStorageWrite(
persistServerList(serverList, subId),
"Failed to publish profile index",
)
if (getSelectServer().isNullOrBlank()) {
requireStorageWrite(
mainStorage.encode(KEY_SELECTED_SERVER, key),
"Failed to update selected profile",
)
}
}
}
@@ -184,13 +297,88 @@ object MmkvManager {
}
/**
* Encodes the server configuration directly without updating serverList.
* Saves a profile batch before publishing its group index and removing replaced payloads.
*
* @param key The server GUID.
* @param configJson The server configuration JSON string.
* @param profiles Generated GUIDs and parsed profiles, in insertion order.
* @param rawConfigs Optional raw configuration payloads keyed by profile GUID.
* @param subscriptionId The destination subscription ID.
* @param append Whether to append to the existing group index.
*/
fun encodeProfileDirect(key: String, configJson: String) {
profileFullStorage.encode(key, configJson)
internal fun saveServerProfiles(
profiles: Map<String, ProfileItem>,
rawConfigs: Map<String, String>,
subscriptionId: String,
append: Boolean,
) {
if (profiles.isEmpty()) return
withProfileIndexLock {
val replacedServers = if (append) {
emptyList()
} else {
decodeServerList(subscriptionId).toList()
}
val previousSelection = getSelectServer()
val selectedProfile = if (!append &&
previousSelection != null &&
previousSelection in replacedServers
) {
decodeServerConfig(previousSelection)
} else {
null
}
val replacementSelection = ProfileReplacement.findSelectedReplacement(
profiles = profiles,
currentSelection = previousSelection,
selectedProfile = selectedProfile,
)
profiles.forEach { (guid, profile) ->
requireStorageWrite(
profileFullStorage.encode(guid, JsonUtil.toJson(profile)),
"Failed to save profile payload",
)
rawConfigs[guid]?.let { raw ->
requireStorageWrite(
serverRawStorage.encode(guid, raw),
"Failed to save raw profile payload",
)
}
}
val serverList = if (append) {
decodeServerList(subscriptionId)
} else {
mutableListOf()
}
val indexedServers = serverList.toHashSet()
profiles.keys.forEach { guid ->
if (indexedServers.add(guid)) {
serverList.add(0, guid)
}
}
requireStorageWrite(
persistServerList(serverList, subscriptionId),
"Failed to publish profile index",
)
replacementSelection?.let { guid ->
requireStorageWrite(
mainStorage.encode(KEY_SELECTED_SERVER, guid),
"Failed to update selected profile",
)
}
if (replacedServers.isEmpty()) return@withProfileIndexLock
val protectedServer = replacementSelection ?: previousSelection
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
val removablePayloads = ProfileReplacement.findRemovablePayloads(
replacedServers = replacedServers,
replacementServers = profiles.keys,
protectedServer = protectedServer,
serversReferencedByOtherGroups = referencedByOtherGroups,
)
removeProfilePayloads(removablePayloads)
}
}
/**
@@ -378,6 +566,56 @@ object MmkvManager {
return serverRawStorage.decodeString(guid)
}
/**
* Removes profile payloads that are provably absent from their raw SUB_SERVERS_* index.
*
* SUB_IDS and SUB are intentionally ignored: either store can be missing after MMKV
* recovery while the group indexes still identify live profiles. If any group index or
* profile payload needed for a decision is unreadable, that data is preserved.
*
* @return The number of profile payloads removed, or null if cleanup could not run safely.
*/
internal fun removeOrphanedServerProfiles(): Int? = synchronized(mainStorage) {
mainStorage.lock()
try {
val indexedServersBySubscription = mainStorage.allKeys().orEmpty()
.asSequence()
.filter { key -> key.startsWith(KEY_SUB_SERVER_PREFIX) }
.associate { key ->
val subscriptionId = key.removePrefix(KEY_SUB_SERVER_PREFIX)
val json = mainStorage.decodeString(key)
val serverIds = if (json.isNullOrBlank()) {
null
} else {
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toSet()
}
subscriptionId to serverIds
}
val profiles = profileFullStorage.allKeys().orEmpty().map { guid ->
StoredProfileReference(
guid = guid,
subscriptionId = decodeServerConfig(guid)?.subscriptionId,
)
}
val orphans = OrphanProfileCleaner.findOrphans(
profiles = profiles,
indexedServersBySubscription = indexedServersBySubscription,
selectedServer = getSelectServer(),
) ?: return@synchronized null
if (orphans.isNotEmpty()) {
val keys = orphans.toTypedArray()
profileFullStorage.removeValuesForKeys(keys)
serverAffStorage.removeValuesForKeys(keys)
serverRawStorage.removeValuesForKeys(keys)
}
orphans.size
} finally {
mainStorage.unlock()
}
}
//endregion
//region Subscriptions
@@ -480,7 +718,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()
}
}
@@ -15,13 +15,13 @@ 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.ui.main.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.min
@@ -248,7 +248,8 @@ object NotificationManager {
}
}
stat.tag.startsWith(AppConfig.TAG_PROXY) -> {
// Accumulate stats for all proxy outbounds (including custom subscription tags)
stat.tag != AppConfig.TAG_BLOCKED -> {
when (stat.direction) {
AppConfig.UPLINK -> proxyUplink += stat.value
AppConfig.DOWNLINK -> proxyDownlink += stat.value
@@ -0,0 +1,53 @@
package com.v2ray.ang.handler
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
internal data class StoredProfileReference(
val guid: String,
val subscriptionId: String?,
)
internal object OrphanProfileCleaner {
/**
* Finds profile payloads that are provably unreachable from the raw group indexes.
*
* A null subscription ID means that the profile payload could not be decoded and is
* preserved. A null server set means that a group index could not be decoded, in which
* case the entire classification returns null and no cleanup should run.
* Subscription metadata is deliberately not an input because SUB and SUB_IDS can be
* missing while the raw SUB_SERVERS_* indexes remain intact.
*/
fun findOrphans(
profiles: Collection<StoredProfileReference>,
indexedServersBySubscription: Map<String, Set<String>?>,
selectedServer: String?,
): Set<String>? {
if (profiles.isEmpty()) return emptySet()
if (indexedServersBySubscription.isEmpty() ||
indexedServersBySubscription.values.any { it == null }
) {
return null
}
val indexedServers = indexedServersBySubscription.values
.filterNotNull()
.flatten()
.toSet()
return profiles.mapNotNullTo(linkedSetOf()) { profile ->
if (profile.guid == selectedServer || profile.guid in indexedServers) {
return@mapNotNullTo null
}
val subscriptionId = profile.subscriptionId ?: return@mapNotNullTo null
val groupId = subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
if (!indexedServersBySubscription.containsKey(groupId)) {
return@mapNotNullTo null
}
profile.guid
}
}
}
@@ -0,0 +1,76 @@
package com.v2ray.ang.handler
import com.v2ray.ang.dto.entities.ProfileItem
internal object ProfileReplacement {
/**
* Finds the profile that should become selected after publishing a replacement batch.
* The first profile becomes selected when the store has no current selection.
*/
fun findSelectedReplacement(
profiles: Map<String, ProfileItem>,
currentSelection: String?,
selectedProfile: ProfileItem?,
): String? {
if (profiles.isEmpty()) return null
if (currentSelection.isNullOrBlank()) return profiles.keys.first()
if (selectedProfile == null) return null
if (selectedProfile.remarks.isNotBlank()) {
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.remarks, selectedProfile.remarks) &&
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
isSameText(candidate.password, selectedProfile.password)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.remarks, selectedProfile.remarks)
}?.key?.let { return it }
}
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
isSameText(candidate.password, selectedProfile.password)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server) &&
isSameText(candidate.serverPort, selectedProfile.serverPort)
}?.key?.let { return it }
profiles.entries.firstOrNull { (_, candidate) ->
isSameText(candidate.server, selectedProfile.server)
}?.key?.let { return it }
return profiles.keys.firstOrNull()
}
/**
* Finds replaced payloads that are safe to remove.
*
* A null cross-group reference set means that at least one raw group index could not
* be read. In that case deletion fails closed.
*/
fun findRemovablePayloads(
replacedServers: Collection<String>,
replacementServers: Set<String>,
protectedServer: String?,
serversReferencedByOtherGroups: Set<String>?,
): Set<String> {
if (serversReferencedByOtherGroups == null) return emptySet()
return replacedServers.filterTo(linkedSetOf()) { guid ->
guid != protectedServer &&
guid !in replacementServers &&
guid !in serversReferencedByOtherGroups
}
}
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
}
}
@@ -19,6 +19,7 @@ object SettingsChangeManager {
AppConfig.PREF_GROUP_ALL_DISPLAY,
AppConfig.PREF_LANGUAGE,
AppConfig.PREF_UI_MODE_NIGHT,
AppConfig.PREF_DYNAMIC_COLOR,
AppConfig.PREF_IS_BOOTED,
)
@@ -16,7 +16,6 @@ import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.extension.moveItem
@@ -31,7 +30,6 @@ import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.io.File
import java.io.FileOutputStream
import java.util.Locale
import kotlin.random.Random
object SettingsManager {
@@ -62,12 +60,11 @@ object SettingsManager {
/**
* Get preset routing rulesets.
* @param context The application context.
* @param index The index of the routing type.
* @param type The routing preset type.
* @return A mutable list of RulesetItem.
*/
private fun getPresetRoutingRulesets(context: Context, index: Int = 0): MutableList<RulesetItem>? {
val fileName = RoutingType.fromIndex(index).fileName
val assets = Utils.readTextFromAssets(context, fileName)
private fun getPresetRoutingRulesets(context: Context, type: RoutingType = RoutingType.WHITE): MutableList<RulesetItem>? {
val assets = Utils.readTextFromAssets(context, type.fileName)
if (TextUtils.isEmpty(assets)) {
return null
}
@@ -78,10 +75,10 @@ object SettingsManager {
/**
* Reset routing rulesets from presets.
* @param context The application context.
* @param index The index of the routing type.
* @param type The routing preset type.
*/
fun resetRoutingRulesetsFromPresets(context: Context, index: Int) {
val rulesetList = getPresetRoutingRulesets(context, index) ?: return
fun resetRoutingRulesetsFromPresets(context: Context, type: RoutingType) {
val rulesetList = getPresetRoutingRulesets(context, type) ?: return
resetRoutingRulesetsCommon(rulesetList)
}
@@ -179,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") {
@@ -389,29 +386,6 @@ object SettingsManager {
return value.coerceIn(1, 128)
}
/**
* Get the locale.
* @return The locale.
*/
fun getLocale(): Locale {
val langCode =
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
val language = Language.fromCode(langCode)
return when (language) {
Language.AUTO -> Utils.getSysLocale()
Language.ENGLISH -> Locale.ENGLISH
Language.CHINA -> Locale.CHINA
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
Language.VIETNAMESE -> Locale.forLanguageTag("vi")
Language.RUSSIAN -> Locale.forLanguageTag("ru")
Language.PERSIAN -> Locale.forLanguageTag("fa")
Language.ARABIC -> Locale.forLanguageTag("ar")
Language.BANGLA -> Locale.forLanguageTag("bn")
Language.BAKHTIARI -> Locale.forLanguageTag("bqi-IR")
}
}
/**
* Retrieves the currently selected VPN interface address configuration.
* This method reads the user's preference for VPN interface addressing and returns
@@ -493,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")
@@ -13,6 +13,11 @@ import java.net.UnknownHostException
object SpeedtestManager {
data class RemoteEndpointInfo(
val country: String?,
val ipAddress: String?,
)
/**
* Measures the time taken to establish a TCP connection to a given URL and port.
*
@@ -48,7 +53,7 @@ object SpeedtestManager {
return -1
}
fun getRemoteIPInfo(): String? {
fun getRemoteIPInfo(): RemoteEndpointInfo? {
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
@@ -81,6 +86,9 @@ object SpeedtestManager {
ipInfo.location?.country_code
).firstOrNull { !it.isNullOrBlank() }
return "(${country ?: "unknown"}) ${ip ?: "unknown"}"
return RemoteEndpointInfo(
country = country,
ipAddress = ip,
)
}
}
@@ -1,5 +1,7 @@
package com.v2ray.ang.helper
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -27,6 +29,37 @@ object MessageHelper {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
}
/**
* Sends an ordered service message and reports whether a daemon receiver handled it.
* With no running daemon, the initial canceled result reaches [onResult] unchanged.
*/
internal fun sendMsg2ServiceForResult(
ctx: Context,
what: Int,
content: Serializable,
onResult: (handled: Boolean) -> Unit,
) {
val resultReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
onResult(resultCode == Activity.RESULT_OK)
}
}
try {
ctx.sendOrderedBroadcast(
messageIntent(AppConfig.BROADCAST_ACTION_SERVICE, what, content),
null,
resultReceiver,
null,
Activity.RESULT_CANCELED,
null,
null,
)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send ordered message to service", e)
onResult(false)
}
}
/**
* Sends a message to the UI.
*
@@ -115,14 +148,16 @@ object MessageHelper {
*/
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
try {
val intent = Intent()
intent.action = action
intent.`package` = AppConfig.ANG_PACKAGE
intent.putExtra("key", what)
intent.putExtra("content", content)
ctx.sendBroadcast(intent)
ctx.sendBroadcast(messageIntent(action, what, content))
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
}
}
}
private fun messageIntent(action: String, what: Int, content: Serializable): Intent =
Intent(action).apply {
`package` = AppConfig.ANG_PACKAGE
putExtra("key", what)
putExtra("content", content)
}
}
@@ -77,15 +77,18 @@ object NotificationHelper {
* @param channelType The notification channel type
* @param title The notification title
* @param content The notification content text
* @param action An optional action retained when the notification is updated
*/
fun startForeground(
service: Service,
channelType: NotificationChannelType,
title: String,
content: String
content: String,
action: NotificationCompat.Action? = null
) {
ensureChannelCreated(channelType, service)
val builder = buildNotificationBuilder(channelType, service, title, content)
val builder = buildNotificationBuilder(channelType, service, title, content, action)
builderCache[channelType.notificationId] = builder
service.startForeground(channelType.notificationId, builder.build())
}
@@ -141,7 +144,8 @@ object NotificationHelper {
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
content: String,
action: NotificationCompat.Action? = null
): NotificationCompat.Builder {
val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
channelType.channelId
@@ -158,5 +162,6 @@ object NotificationHelper {
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.apply { action?.let(::addAction) }
}
}
}
@@ -36,7 +36,10 @@ class PermissionHelper(private val activity: ComponentActivity) {
if (isGranted) {
onGranted()
} else {
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
val message = activity.getString(
R.string.toast_permission_denied_for,
activity.getString(permissionType.getLabelRes())
)
activity.toast(message)
}
}
@@ -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("'", "''")}'"
@@ -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
@@ -7,10 +7,9 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import java.lang.ref.SoftReference
class CoreProxyOnlyService : Service(), ServiceControl {
@@ -34,6 +33,11 @@ class CoreProxyOnlyService : Service(), ServiceControl {
NotificationManager.ensureForeground()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
if (CoreServiceManager.isRunning()) {
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Core is already running")
return START_STICKY
}
if (!CoreServiceManager.startCoreLoop(null)) {
LogUtil.e(AppConfig.TAG, "StartCore-Proxy: Failed to start core loop")
stopSelf()
@@ -96,9 +100,7 @@ class CoreProxyOnlyService : Service(), ServiceControl {
* @param newBase The new base context.
*/
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
val context = newBase?.let(AppLocaleManager::localizedContext)
super.attachBaseContext(context)
}
}
@@ -7,11 +7,10 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.root.RootProxyManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -43,6 +42,11 @@ class CoreRootService : Service(), ServiceControl {
NotificationManager.ensureForeground()
LogUtil.i(AppConfig.TAG, "StartCore-Root: command received")
if (CoreServiceManager.isRunning()) {
LogUtil.i(AppConfig.TAG, "StartCore-Root: Core is already running")
return START_STICKY
}
// Start the in-process core first (this also posts the foreground notification),
// then install the root routing off the main thread.
if (!CoreServiceManager.startCoreLoop(null)) {
@@ -90,9 +94,7 @@ class CoreRootService : Service(), ServiceControl {
override fun onBind(intent: Intent?): IBinder? = null
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
val context = newBase?.let(AppLocaleManager::localizedContext)
super.attachBaseContext(context)
}
}
@@ -1,8 +1,11 @@
package com.v2ray.ang.service
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
@@ -10,6 +13,8 @@ import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.helper.NotificationHelper
@@ -18,8 +23,29 @@ import java.util.Collections
class CoreTestService : Service() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
}
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val cancelAction by lazy {
val intent = Intent(this, CoreTestService::class.java).putExtra(
"content",
TestServiceMessage(AppConfig.MSG_MEASURE_CONFIG_CANCEL)
)
val pendingIntent = PendingIntent.getService(
this,
NotificationChannelType.CORE_TEST.notificationId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
NotificationCompat.Action.Builder(
R.drawable.ic_stop_24dp,
getString(R.string.action_cancel),
pendingIntent
).build()
}
/**
* Initializes the V2Ray environment.
@@ -63,7 +89,8 @@ class CoreTestService : Service() {
this,
NotificationChannelType.CORE_TEST,
getString(R.string.app_name),
getString(R.string.title_real_ping_all_server)
getString(R.string.title_real_ping_all_server),
cancelAction
)
val message = intent?.serializable<TestServiceMessage>("content")
if (message == null) {
@@ -96,7 +123,7 @@ class CoreTestService : Service() {
context = this,
guids = guidsList,
onlyTcp = message.onlyTcp,
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
onEvent = { event -> handleWorkerEvent(event, message) { activeWorkers.remove(worker) } }
)
activeWorkers.add(worker)
worker.start()
@@ -106,14 +133,14 @@ class CoreTestService : Service() {
}
}
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
channelType = NotificationChannelType.CORE_TEST,
context = this,
title = getString(R.string.app_name),
content = getString(R.string.connection_runing_task_left, event.text)
content = getString(R.string.connection_running_task_left, event.text)
)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
}
@@ -124,6 +151,16 @@ class CoreTestService : Service() {
}
is RealPingEvent.Finish -> {
if (message.subscriptionId.isNotEmpty()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
AngConfigManager.removeInvalidServer(message.subscriptionId)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
AngConfigManager.sortByTestResultsForSub(message.subscriptionId)
}
}
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
onWorkerDone()
if (activeWorkers.isEmpty()) {
@@ -135,6 +172,7 @@ 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() }
@@ -17,12 +17,12 @@ import com.v2ray.ang.BuildConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.root.RootLanSharing
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
import java.util.concurrent.atomic.AtomicBoolean
@@ -129,9 +129,7 @@ class CoreVpnService : VpnService(), ServiceControl {
}
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
val context = newBase?.let(AppLocaleManager::localizedContext)
super.attachBaseContext(context)
}
@@ -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 kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.Call
@@ -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 kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@@ -12,6 +12,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.core.LauncherManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
@@ -19,6 +20,10 @@ import java.lang.ref.SoftReference
class QSTileService : TileService() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
}
/**
* Sets the state of the tile.
* @param state The state to set.
@@ -15,11 +15,29 @@ import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.isActive
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
internal object RealPingExecutionLimiter {
private val customConfigMutex = Mutex()
suspend fun <T> run(configType: EConfigType, block: () -> T): T {
// Custom profiles bypass speed-test trimming and start complete Xray configs.
// Parallel teardown can abort the native probe process, so serialize their
// JNI measurements globally across batches.
return if (configType == EConfigType.CUSTOM) {
customConfigMutex.withLock { block() }
} else {
block()
}
}
}
/**
* Worker that runs a batch of real-ping tests independently.
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
@@ -45,13 +63,17 @@ class RealPingWorkerService(
runningCount.incrementAndGet()
try {
val result = if (onlyTcp) startTcping(guid) else startRealPing(guid)
onEvent(RealPingEvent.Result(guid, result))
if (scope.isActive) {
onEvent(RealPingEvent.Result(guid, result))
}
} catch (_: Throwable) {
// ignore
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
onEvent(RealPingEvent.Progress("$left / $count"))
if (scope.isActive) {
onEvent(RealPingEvent.Progress("$left / $count"))
}
}
}
}
@@ -59,9 +81,11 @@ class RealPingWorkerService(
scope.launch {
try {
joinAll(*jobs.toTypedArray())
onEvent(RealPingEvent.Finish("0"))
if (isActive) {
onEvent(RealPingEvent.Finish("0"))
}
} catch (_: CancellationException) {
onEvent(RealPingEvent.Finish("-1"))
// If cancelled, don't send finish event to avoid confusion
} finally {
close()
}
@@ -80,7 +104,7 @@ class RealPingWorkerService(
}
}
private fun startRealPing(guid: String): Long {
private suspend fun startRealPing(guid: String): Long {
val retFailure = -1L
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
@@ -103,7 +127,9 @@ class RealPingWorkerService(
if (!configResult.status) {
return retFailure
}
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
return RealPingExecutionLimiter.run(config.configType) {
CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
private fun startTcping(guid: String): Long {
@@ -113,7 +139,7 @@ class RealPingWorkerService(
if (!config.configType.isComplexType()
&& config.configType != EConfigType.HYSTERIA2
&& config.configType != EConfigType.WIREGUARD
&& config.alpn?.startsWith("h3") != true
&& config.alpn?.split(',')?.all { it.trim().startsWith("h3") } != true
&& config.server.isNotNullEmpty()
&& config.serverPort?.toIntOrNull() != null
) {
@@ -13,6 +13,7 @@ import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.util.LogUtil
@@ -28,6 +29,10 @@ import java.util.concurrent.atomic.AtomicInteger
class SubscriptionUpdateService : Service() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
}
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@@ -118,7 +123,7 @@ class SubscriptionUpdateService : Service() {
showNotification(
context = this,
titleResId = R.string.title_pref_auto_update_subscription,
content = "Updating ${subItem.remarks}"
content = getString(R.string.subscription_update_updating, subItem.remarks)
)
if (forcedUpdate || MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)) {
@@ -184,13 +189,17 @@ class SubscriptionUpdateService : Service() {
private fun handleWorkerEvent(event: RealPingEvent, remarks: String, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
val text = "${event.text} in $remarks"
val notificationText = getString(
R.string.subscription_update_progress,
event.text,
remarks
)
showNotification(
context = this,
titleResId = R.string.title_real_ping_all_server,
content = text
content = notificationText
)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: $text")
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: ${event.text} in $remarks")
}
is RealPingEvent.Result -> {
@@ -21,11 +21,15 @@ class TProxyService(
companion object {
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStartService(configPath: String, fd: Int)
private external fun TProxyStartService(configPath: String, fd: Int): Boolean
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStopService()
private external fun TProxyStopService(): Boolean
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyIsRunning(): Boolean
@JvmStatic
@Suppress("FunctionName")
@@ -93,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,8 +1,10 @@
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
@@ -12,7 +14,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -32,6 +33,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
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.ui.compose.SettingsMenuItem
import com.v2ray.ang.ui.compose.VersionInfoBlock
import com.v2ray.ang.util.Utils
@@ -44,20 +46,29 @@ 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) }
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
val libVersion = CoreNativeManager.getLibVersion()
val versionText = "v${BuildConfig.VERSION_NAME} ($libVersion)"
val appIdText = BuildConfig.APPLICATION_ID
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = stringResource(R.string.title_about),
@@ -81,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),
@@ -100,6 +116,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
versionText = versionText,
appIdText = appIdText
)
NavigationBarsSpacer()
}
}
@@ -121,7 +138,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
},
confirmButton = {
TextButton(onClick = { showOssDialog = false }) {
Text(stringResource(android.R.string.ok))
Text(stringResource(R.string.action_ok))
}
},
containerColor = MaterialTheme.colorScheme.surface,
@@ -0,0 +1,50 @@
package com.v2ray.ang.ui
/**
* Pure selection operations shared by application-list screens.
*
* Keeping the calculations separate lets bulk actions build one result before it is published.
*/
internal object AppSelection {
fun invert(currentSelection: Set<String>, packageNames: Collection<String>): Set<String> {
return currentSelection.toMutableSet().apply {
packageNames.forEach { packageName ->
if (!add(packageName)) {
remove(packageName)
}
}
}
}
/**
* Builds the selected package set while preserving the legacy list matching behavior.
*
* The full list text is searched for each installed package name. This is intentionally not
* exact line matching; changing that behavior should be handled separately.
*/
fun fromProxyList(
packageNames: Collection<String>,
proxyAppList: String,
bypassApps: Boolean,
forceGoogleApps: Boolean
): Set<String> {
return buildSet(packageNames.size) {
packageNames.forEach { packageName ->
val shouldProxy = shouldProxy(packageName, proxyAppList, forceGoogleApps)
val shouldSelect = if (bypassApps) !shouldProxy else shouldProxy
if (shouldSelect) {
add(packageName)
}
}
}
}
private fun shouldProxy(packageName: String, proxyAppList: String, forceGoogleApps: Boolean): Boolean {
if (forceGoogleApps) {
if (packageName == "com.google.android.webview") return false
if (packageName.startsWith("com.google")) return true
}
return proxyAppList.contains(packageName)
}
}
@@ -0,0 +1 @@
@AGENTS.md
@@ -0,0 +1 @@
@AGENTS.md
@@ -69,6 +69,7 @@ import com.v2ray.ang.ui.base.HelperBaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import java.nio.ByteBuffer
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import android.util.Size as TargetSize
@@ -178,7 +179,9 @@ fun ScannerScreen(
if (isScanning) R.drawable.ic_stop_24dp
else R.drawable.ic_scan_24dp
),
contentDescription = if (isScanning) "stop scan" else "start scan"
contentDescription = stringResource(
if (isScanning) R.string.acc_stop_scanner else R.string.acc_start_scanner
)
)
}
if (isScanning && hasTorch) {
@@ -193,14 +196,17 @@ fun ScannerScreen(
if (torchEnabled) R.drawable.ic_flash_on_24dp
else R.drawable.ic_flash_off_24dp
),
contentDescription = "Torch"
contentDescription = stringResource(
if (torchEnabled) R.string.acc_turn_torch_off
else R.string.acc_turn_torch_on
)
)
}
}
IconButton(onClick = onSelectPhoto) {
Icon(
painterResource(R.drawable.ic_image_24dp),
contentDescription = "select image"
contentDescription = stringResource(R.string.acc_select_image)
)
}
}
@@ -244,7 +250,7 @@ private fun ScannerIdlePlaceholder(onStartClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = painterResource(R.drawable.ic_scan_24dp),
contentDescription = "Start Scanner",
contentDescription = stringResource(R.string.acc_start_scanner),
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
@@ -410,15 +416,14 @@ private fun processImageProxy(
return
}
try {
val buffer = imageProxy.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
val width = imageProxy.width
val height = imageProxy.height
val source = PlanarYUVLuminanceSource(
bytes, width, height,
0, 0, width, height,
false
val yPlane = imageProxy.planes[0]
val source = createYPlaneLuminanceSource(
buffer = yPlane.buffer,
width = width,
height = height,
rowStride = yPlane.rowStride,
)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val hints = mapOf(
@@ -437,3 +442,26 @@ private fun processImageProxy(
imageProxy.close()
}
}
internal fun createYPlaneLuminanceSource(
buffer: ByteBuffer,
width: Int,
height: Int,
rowStride: Int,
): PlanarYUVLuminanceSource {
val bufferCopy = buffer.duplicate()
val bytes = ByteArray(bufferCopy.remaining())
bufferCopy.get(bytes)
// Camera HALs may pad each Y row. ZXing advances rows by dataWidth, so the
// reported row stride must be used instead of the visible image width.
return PlanarYUVLuminanceSource(
bytes,
rowStride,
height,
0,
0,
width,
height,
false,
)
}
@@ -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)
)
}
}
@@ -4,21 +4,19 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -33,11 +31,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.ui.base.BaseComponentActivity
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.ItemDivider
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.verticalScrollbar
private enum class AppPickerMenuAction(@StringRes val labelRes: Int) {
SelectAll(R.string.menu_item_select_all),
InvertSelection(R.string.menu_item_invert_selection)
}
class AppPickerActivity : BaseComponentActivity() {
companion object {
@@ -101,7 +106,6 @@ class AppPickerActivity : BaseComponentActivity() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppPickerScreen(
title: String,
@@ -119,12 +123,12 @@ fun AppPickerScreen(
var showMenu by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
LaunchedEffect(searchQuery) {
LaunchedEffect(Unit) {
onSearch(searchQuery)
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = title,
@@ -134,9 +138,11 @@ fun AppPickerScreen(
searchQuery = searchQuery,
onSearchQueryChange = { query ->
searchQuery = query
onSearch(query)
},
onSearchClose = {
searchQuery = ""
onSearch("")
showSearch = false
},
searchPlaceholder = stringResource(R.string.menu_item_search),
@@ -145,7 +151,7 @@ fun AppPickerScreen(
IconButton(onClick = { showSearch = true }) {
Icon(
painterResource(R.drawable.ic_search_24dp),
contentDescription = stringResource(R.string.menu_item_search)
contentDescription = stringResource(R.string.acc_search)
)
}
}
@@ -161,14 +167,13 @@ fun AppPickerScreen(
onDismissRequest = { showMenu = false },
containerColor = MaterialTheme.colorScheme.surface
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_select_all)) },
onClick = { showMenu = false; onSelectAll() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_invert_selection)) },
onClick = { showMenu = false; onInvertSelection() }
)
AppDropdownMenuItems(AppPickerMenuAction.entries, { it.labelRes }) { action ->
showMenu = false
when (action) {
AppPickerMenuAction.SelectAll -> onSelectAll()
AppPickerMenuAction.InvertSelection -> onInvertSelection()
}
}
}
}
}
@@ -180,14 +185,15 @@ fun AppPickerScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScrollbar(listState)
.verticalScrollbar(listState),
contentPadding = NavigationBarsBottomPadding()
) {
items(items = apps, key = { it.packageName }) { app ->
val checked = selectedPackages.contains(app.packageName)
AppListItem(
appName = app.appName,
packageName = app.packageName,
icon = app.appIcon,
icon = null,
checked = checked,
onCheckedChange = { onToggleApp(app.packageName) }
)
@@ -5,6 +5,7 @@ import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.ui.AppSelection
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.LogUtil
@@ -40,16 +41,30 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
}
fun loadApps(context: Context) {
if (allApps != null || isAppListLoading) return
allApps?.let { apps ->
val localizedUnknownApp = getString(R.string.app_picker_unknown_app)
val updatedApps = apps.map { appInfo ->
if (appInfo.packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
appInfo.copy(appName = localizedUnknownApp)
} else {
appInfo
}
}
allApps = sortApps(updatedApps)
_displayedApps.value = applyFilter(currentQuery)
return
}
if (isAppListLoading) return
val applicationContext = context.applicationContext
val localizedUnknownApp = getString(R.string.app_picker_unknown_app)
isAppListLoading = true
launchLoading {
try {
selectedSnapshot = _selectedPackages.value
val apps = withContext(Dispatchers.IO) {
val list = AppManagerUtil.loadNetworkAppList(applicationContext)
val special = createSpecialItemUnidentified(applicationContext)
val special = createSpecialItemUnidentified(localizedUnknownApp)
sortApps(list + special)
}
allApps = apps
@@ -80,15 +95,17 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
}
fun selectAll() {
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
_selectedPackages.value = _selectedPackages.value + displayedPackages
val displayedApps = _displayedApps.value
val currentSelection = _selectedPackages.value
_selectedPackages.value = buildSet(currentSelection.size + displayedApps.size) {
addAll(currentSelection)
displayedApps.forEach { add(it.packageName) }
}
}
fun invertSelection() {
val current = _selectedPackages.value
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
_selectedPackages.value = (current - displayedPackages) +
(displayedPackages - current)
val packageNames = _displayedApps.value.map { it.packageName }
_selectedPackages.value = AppSelection.invert(_selectedPackages.value, packageNames)
}
fun getSelectedPackages(): List<String> = _selectedPackages.value.sorted()
@@ -97,9 +114,9 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
val apps = allApps ?: return emptyList()
if (query.isBlank()) return apps
val key = query.uppercase()
return apps.filter {
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
it.appName.contains(query, ignoreCase = true) ||
it.packageName.contains(query, ignoreCase = true)
}
}
@@ -119,14 +136,10 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
}
}
private fun createSpecialItemUnidentified(context: Context): AppInfo {
val icon = context.getDrawable(android.R.drawable.ic_menu_help)
?: context.getDrawable(android.R.drawable.sym_def_app_icon)
?: error("No fallback drawable available")
private fun createSpecialItemUnidentified(appName: String): AppInfo {
return AppInfo(
appName = context.getString(R.string.app_picker_unknown_app),
appName = appName,
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false,
isSelected = 0
)
@@ -4,15 +4,16 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.viewModels
import androidx.annotation.StringRes
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -37,8 +38,10 @@ import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager
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.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
@@ -48,14 +51,15 @@ import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
enum class BackupLocation(@StringRes val labelRes: Int) {
Local(R.string.backup_location_local),
WebDav(R.string.backup_location_webdav)
}
class BackupActivity : HelperBaseComponentActivity() {
private val viewModel: BackupViewModel by viewModels()
private val configBackupOptions: Array<out String> by lazy {
resources.getStringArray(R.array.config_backup_options)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
observeViewModel()
@@ -90,21 +94,20 @@ class BackupActivity : HelperBaseComponentActivity() {
BackupScreen(
isLoadingState = viewModel.isLoading,
webDavConfigState = viewModel.webDavConfig,
backupOptions = configBackupOptions.toList(),
onBackupOptionSelected = { which ->
when (which) {
0 -> backupViaLocal()
1 -> viewModel.backupViaWebDav(cacheDir, getString(R.string.app_name))
onBackupOptionSelected = { location ->
when (location) {
BackupLocation.Local -> backupViaLocal()
BackupLocation.WebDav -> viewModel.backupViaWebDav(cacheDir, getString(R.string.app_name))
}
},
onShareClick = { viewModel.shareBackup(cacheDir, getString(R.string.app_name)) },
restoreOptions = configBackupOptions.toList(),
onRestoreOptionSelected = { which ->
when (which) {
0 -> restoreViaLocal()
1 -> viewModel.restoreViaWebDav(cacheDir)
onRestoreOptionSelected = { location ->
when (location) {
BackupLocation.Local -> restoreViaLocal()
BackupLocation.WebDav -> viewModel.restoreViaWebDav(cacheDir)
}
},
onCleanupProfiles = viewModel::cleanupProfileStorage,
onWebDavSave = { config -> viewModel.saveWebDavConfig(config) },
onBackClick = { finish() }
)
@@ -183,11 +186,10 @@ class BackupActivity : HelperBaseComponentActivity() {
fun BackupScreen(
isLoadingState: StateFlow<Boolean>,
webDavConfigState: StateFlow<WebDavConfig?>,
backupOptions: List<String>,
onBackupOptionSelected: (Int) -> Unit,
onBackupOptionSelected: (BackupLocation) -> Unit,
onShareClick: () -> Unit,
restoreOptions: List<String>,
onRestoreOptionSelected: (Int) -> Unit,
onRestoreOptionSelected: (BackupLocation) -> Unit,
onCleanupProfiles: () -> Unit,
onWebDavSave: (WebDavConfig) -> Unit,
onBackClick: () -> Unit
) {
@@ -195,12 +197,13 @@ fun BackupScreen(
val currentWebDavConfig by webDavConfigState.collectAsState()
var showBackupDialog by remember { mutableStateOf(false) }
var showRestoreDialog by remember { mutableStateOf(false) }
var showCleanupDialog by remember { mutableStateOf(false) }
var showWebDavDialog by remember { mutableStateOf(false) }
val webDavSummary = currentWebDavConfig?.baseUrl
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = stringResource(R.string.title_configuration_backup_restore),
@@ -230,6 +233,12 @@ fun BackupScreen(
title = stringResource(R.string.title_configuration_restore),
onClick = { showRestoreDialog = true }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_delete_24dp),
title = stringResource(R.string.title_profile_storage_cleanup),
subtitle = stringResource(R.string.summary_profile_storage_cleanup),
onClick = { showCleanupDialog = true }
)
Spacer(modifier = Modifier.height(16.dp))
SettingsMenuItem(
icon = painterResource(R.drawable.ic_settings_24dp),
@@ -237,16 +246,18 @@ fun BackupScreen(
subtitle = webDavSummary,
onClick = { showWebDavDialog = true }
)
NavigationBarsSpacer()
}
}
if (showBackupDialog) {
SelectListDialog(
title = stringResource(R.string.title_configuration_backup),
options = backupOptions,
onSelected = { index, _ ->
options = BackupLocation.entries,
optionText = { stringResource(it.labelRes) },
onSelected = { location ->
showBackupDialog = false
onBackupOptionSelected(index)
onBackupOptionSelected(location)
},
onDismiss = { showBackupDialog = false }
)
@@ -254,14 +265,22 @@ fun BackupScreen(
if (showRestoreDialog) {
SelectListDialog(
title = stringResource(R.string.title_configuration_restore),
options = restoreOptions,
onSelected = { index, _ ->
options = BackupLocation.entries,
optionText = { stringResource(it.labelRes) },
onSelected = { location ->
showRestoreDialog = false
onRestoreOptionSelected(index)
onRestoreOptionSelected(location)
},
onDismiss = { showRestoreDialog = false }
)
}
if (showCleanupDialog) {
DeleteConfirmDialog(
message = stringResource(R.string.message_profile_storage_cleanup),
onConfirm = onCleanupProfiles,
onDismiss = { showCleanupDialog = false }
)
}
if (showWebDavDialog) {
WebDavInputDialog(
initialConfig = currentWebDavConfig,
@@ -317,7 +336,7 @@ private fun WebDavInputDialog(
}
},
confirmText = stringResource(R.string.menu_item_save_config),
dismissText = stringResource(android.R.string.cancel),
dismissText = stringResource(R.string.action_cancel),
onConfirm = {
onSave(
WebDavConfig(
@@ -13,9 +13,11 @@ import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.ui.base.ViewModelEvent
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
@@ -37,6 +39,24 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
toastSuccess(R.string.toast_success)
}
fun cleanupProfileStorage() {
launchLoading {
try {
val removed = withContext(Dispatchers.IO) {
MmkvManager.removeOrphanedServerProfiles()
}
if (removed == null) {
toastError(R.string.toast_profile_storage_cleanup_skipped)
} else {
toastSuccess(getString(R.string.toast_profile_storage_cleanup, removed))
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to clean up profile storage", e)
toastError(R.string.toast_failure)
}
}
}
fun shareBackup(cacheDir: File, appName: String) {
launchLoading {
val ret = backupConfigurationToCache(cacheDir, appName)
@@ -165,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
}
}
}
@@ -1,23 +1,18 @@
package com.v2ray.ang.ui.base
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.ui.compose.AppTheme
import com.v2ray.ang.util.MyContextWrapper
abstract class BaseComponentActivity : ComponentActivity() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
}
abstract class BaseComponentActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AppLocaleManager.onActivityCreated(this)
enableEdgeToEdge()
setContent {
AppTheme {
@@ -1,12 +1,14 @@
package com.v2ray.ang.ui.base
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AngApplication
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AppLocaleManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +29,9 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
application as AngApplication
}
protected val localizedContext: Context
get() = AppLocaleManager.localizedContext(app)
@Suppress("PropertyName")
protected val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
@@ -39,7 +44,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
* Send neutral toast event (Resource ID).
*/
fun toast(resId: Int) {
app.toast(resId)
localizedContext.toast(resId)
}
/**
@@ -53,7 +58,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
* Send success toast event (Resource ID).
*/
fun toastSuccess(resId: Int) {
app.toastSuccess(resId)
localizedContext.toastSuccess(resId)
}
/**
@@ -67,7 +72,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
* Send error toast event (Resource ID).
*/
fun toastError(resId: Int) {
app.toastError(resId)
localizedContext.toastError(resId)
}
/**
@@ -81,14 +86,21 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
* Get string from resource ID.
*/
fun getString(resId: Int): String {
return app.getString(resId)
return localizedContext.getString(resId)
}
/**
* Get formatted string from resource ID.
*/
fun getString(resId: Int, vararg formatArgs: Any?): String {
return app.getString(resId, *formatArgs)
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)
}
/**
@@ -113,4 +125,4 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
}
}
}
}
}
@@ -3,18 +3,18 @@ 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -26,9 +26,11 @@ import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
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.ui.compose.SettingsMenuItem
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.VersionInfoBlock
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.Utils
class CheckUpdateActivity : BaseComponentActivity() {
@@ -37,6 +39,9 @@ class CheckUpdateActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
viewModel.checkForUpdates()
}
}
@Composable
@@ -57,14 +62,11 @@ fun CheckUpdateScreen(
val showUpdateDialog by viewModel.showUpdateDialog.collectAsStateWithLifecycle()
val updateResult by viewModel.updateResult.collectAsStateWithLifecycle()
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
LaunchedEffect(Unit) {
viewModel.checkForUpdates()
}
val libVersion = CoreNativeManager.getLibVersion()
val versionText = "v${BuildConfig.VERSION_NAME} ($libVersion)"
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = stringResource(R.string.update_check_for_update),
@@ -91,6 +93,7 @@ fun CheckUpdateScreen(
onClick = { viewModel.checkForUpdates() }
)
VersionInfoBlock(versionText = versionText)
NavigationBarsSpacer()
}
}
@@ -99,7 +102,16 @@ fun CheckUpdateScreen(
AlertDialog(
onDismissRequest = { viewModel.dismissUpdateDialog() },
title = { Text(stringResource(R.string.update_new_version_found, result.latestVersion ?: "")) },
text = { Text(result.releaseNotes ?: "") },
text = {
val scrollState = rememberScrollState()
Text(
text = result.releaseNotes.orEmpty(),
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.verticalScrollbar(scrollState)
)
},
confirmButton = {
TextButton(onClick = {
viewModel.dismissUpdateDialog()
@@ -110,7 +122,7 @@ fun CheckUpdateScreen(
},
dismissButton = {
TextButton(onClick = { viewModel.dismissUpdateDialog() }) {
Text(stringResource(android.R.string.cancel))
Text(stringResource(R.string.action_cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface
@@ -42,12 +42,8 @@ class CheckUpdateViewModel(application: Application) : BaseViewModel(application
toastSuccess(R.string.update_already_latest_version)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
if (e.message == null) {
toastError(R.string.toast_failure)
} else {
toastError(e.message.orEmpty())
}
LogUtil.e(AppConfig.TAG, "Failed to check for updates", e)
toastError(R.string.toast_failure)
}
}
}
@@ -55,4 +51,4 @@ class CheckUpdateViewModel(application: Application) : BaseViewModel(application
fun dismissUpdateDialog() {
_showUpdateDialog.value = false
}
}
}
@@ -1,25 +1,24 @@
package com.v2ray.ang.ui.compose
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.Checkbox
@@ -45,17 +44,21 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
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 com.v2ray.ang.R
import com.v2ray.ang.util.AppIconFetcher
import sh.calvin.reorderable.ReorderableCollectionItemScope
@OptIn(ExperimentalMaterial3Api::class)
@@ -92,7 +95,7 @@ fun AppTopBar(
IconButton(onClick = if (isSearchActive) onSearchClose else onBackClick) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_24dp),
contentDescription = "Back"
contentDescription = stringResource(R.string.acc_back)
)
}
}
@@ -110,7 +113,7 @@ fun AppTopBar(
enter = expandVertically(),
exit = shrinkVertically()
) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = colorFabActive)
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.secondary)
}
}
}
@@ -163,6 +166,7 @@ fun AppListItem(
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
Row(
modifier = modifier
.fillMaxWidth()
@@ -170,41 +174,26 @@ fun AppListItem(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
when (icon) {
is Bitmap -> {
Image(
bitmap = icon.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
is Int -> {
Image(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
is Drawable -> {
val bitmap = remember(icon) { createBitmapFromDrawable(icon) }
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
else -> {
Box(modifier = Modifier.size(40.dp))
}
val model = remember(icon, packageName) {
if (icon != null) {
icon
} else {
val data = "appicon:$packageName"
ImageRequest.Builder(context)
.data(data)
.fetcherFactory(AppIconFetcher.Factory(context))
.build()
}
}
AsyncImage(
model = model,
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit,
error = painterResource(R.drawable.ic_image_24dp),
fallback = painterResource(R.drawable.ic_image_24dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -225,24 +214,11 @@ fun AppListItem(
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(checkedColor = colorFabActive)
colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colorScheme.secondary)
)
}
}
private fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 96
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 96
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
@Composable
fun ItemDivider() {
AppDivider(modifier = Modifier.padding(horizontal = 12.dp))
@@ -254,6 +230,17 @@ fun AppDivider(modifier: Modifier = Modifier) {
HorizontalDivider(modifier = modifier.fillMaxWidth(), thickness = 1.dp, color = color)
}
@Composable
fun NavigationBarsSpacer(modifier: Modifier = Modifier) {
Spacer(modifier = modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
@Composable
fun NavigationBarsBottomPadding(): PaddingValues {
val bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
return PaddingValues(bottom = bottom)
}
@Composable
fun VersionInfoBlock(
versionText: String,
@@ -13,7 +13,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
@@ -34,6 +35,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
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.unit.dp
import com.v2ray.ang.R
@@ -42,8 +44,8 @@ import com.v2ray.ang.R
fun ConfirmDialog(
title: String? = null,
message: String,
confirmText: String = stringResource(android.R.string.ok),
dismissText: String? = stringResource(android.R.string.cancel),
confirmText: String = stringResource(R.string.action_ok),
dismissText: String? = stringResource(R.string.action_cancel),
confirmIcon: @Composable (() -> Unit)? = null,
onConfirm: () -> Unit,
onDismiss: () -> Unit
@@ -173,7 +175,7 @@ fun QRCodeDialog(
text = {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = stringResource(R.string.title_qr_code),
contentDescription = stringResource(R.string.acc_qr_code),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
@@ -192,44 +194,46 @@ fun QRCodeDialog(
* The selectedOption parameter is used to highlight the selected item only when showRadio is true.
*/
@Composable
fun SelectListDialog(
title: String? = null,
options: List<String>,
selectedOption: String = "",
onSelected: (Int, String) -> Unit,
fun <T> SelectListDialog(
options: List<T>,
optionText: @Composable (T) -> String,
onSelected: (T) -> Unit,
onDismiss: () -> Unit,
title: String? = null,
selectedOption: T? = null,
showRadio: Boolean = false
) {
val selectedIndex = if (showRadio) options.indexOf(selectedOption) else -1
AlertDialog(
onDismissRequest = onDismiss,
title = title?.let { { Text(it) } },
text = {
LazyColumn {
itemsIndexed(options) { index, option ->
items(options) { option ->
val isSelected = option == selectedOption
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelected(index, option) }
.padding(vertical = 4.dp),
.then(
if (showRadio) Modifier.selectable(
selected = isSelected,
onClick = { onSelected(option) },
role = Role.RadioButton
) else Modifier.clickable { onSelected(option) }
)
.padding(vertical = 12.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (showRadio) {
RadioButton(
selected = index == selectedIndex,
onClick = { onSelected(index, option) }
selected = isSelected,
onClick = null
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = option,
text = optionText(option),
style = MaterialTheme.typography.bodyMedium,
modifier = if (!showRadio)
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 4.dp)
else Modifier
modifier = Modifier.weight(1f)
)
}
}
@@ -238,7 +242,7 @@ fun SelectListDialog(
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
Text(stringResource(R.string.action_cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface
@@ -78,6 +78,7 @@ fun FormDropdownField(
editable: Boolean = false,
enabled: Boolean = true,
placeholder: String? = null,
supportingText: String? = null,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val menuScrollState = rememberScrollState()
@@ -104,6 +105,7 @@ fun FormDropdownField(
enabled = enabled,
label = { Text(label) },
placeholder = { if (placeholder != null) Text(placeholder) },
supportingText = supportingText?.let { { Text(it) } },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@@ -0,0 +1,20 @@
package com.v2ray.ang.ui.compose
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
@Composable
fun <T> AppDropdownMenuItems(
items: List<T>,
labelRes: (T) -> Int,
onSelected: (T) -> Unit
) {
items.forEach { item ->
DropdownMenuItem(
text = { Text(stringResource(labelRes(item))) },
onClick = { onSelected(item) }
)
}
}
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
@@ -9,6 +8,8 @@ import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
@@ -22,7 +23,10 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.currentStateAsState
import com.v2ray.ang.extension.delay
import kotlinx.coroutines.flow.collectLatest
data class ScrollbarConfig(
@@ -43,8 +47,9 @@ private fun <T> rememberScrollbarAlpha(
key: Any,
config: ScrollbarConfig,
position: () -> T,
): Animatable<Float, AnimationVector1D> {
): State<Float> {
val alpha = remember(key) { Animatable(0f) }
val lifecycleState = LocalLifecycleOwner.current.lifecycle.currentStateAsState()
LaunchedEffect(key, config.fadeOutDurationMs, config.fadeAnimDurationMs) {
snapshotFlow(position).collectLatest {
alpha.snapTo(1f)
@@ -52,7 +57,11 @@ private fun <T> rememberScrollbarAlpha(
alpha.animateTo(0f, tween(config.fadeAnimDurationMs))
}
}
return alpha
return remember(alpha, lifecycleState) {
derivedStateOf {
if (lifecycleState.value == Lifecycle.State.RESUMED) alpha.value else 0f
}
}
}
@Composable
@@ -35,7 +35,7 @@ fun PreferenceGroupHeader(title: String, modifier: Modifier = Modifier) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = colorFabActive,
color = MaterialTheme.colorScheme.secondary,
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
@@ -59,13 +59,13 @@ fun CollapsiblePreferenceGroupHeader(
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = colorFabActive,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.weight(1f)
)
Icon(
painter = painterResource(R.drawable.ic_expand_more_24dp),
contentDescription = null,
tint = colorFabActive,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier
.size(24.dp)
.rotate(if (expanded) 180f else 0f)
@@ -164,8 +164,8 @@ fun SettingsEditItem(
)
),
onFieldChange = { _, v -> text = v },
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
confirmText = stringResource(R.string.action_ok),
dismissText = stringResource(R.string.action_cancel),
onConfirm = { showDialog = false; onValueChanged(text) },
onDismiss = { showDialog = false }
)
@@ -184,8 +184,9 @@ fun SettingsListItem(
enabled: Boolean = true
) {
var showDialog by remember { mutableStateOf(false) }
val selectedIndex = values.indexOf(selectedValue).let { if (it >= 0) it else 0 }
val summary = entries.getOrNull(selectedIndex).orEmpty()
val options = entries.zip(values)
val selectedOption = options.find { it.second == selectedValue } ?: options.firstOrNull()
val summary = selectedOption?.first.orEmpty()
SettingsItemRow(
icon = icon,
@@ -201,11 +202,12 @@ fun SettingsListItem(
if (showDialog) {
SelectListDialog(
title = title,
options = entries,
selectedOption = summary,
onSelected = { index, _ ->
options = options,
optionText = { it.first },
selectedOption = selectedOption,
onSelected = { option ->
showDialog = false
values.getOrNull(index)?.let(onSelected)
onSelected(option.second)
},
onDismiss = { showDialog = false },
showRadio = true
@@ -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 kotlinx.coroutines.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
@@ -88,7 +87,7 @@ class AppSnackbarController(
if (currentShowTime != 0L) {
val elapsed = System.currentTimeMillis() - currentShowTime
if (elapsed < SnackbarThrottleMs) {
delay((SnackbarThrottleMs - elapsed).milliseconds)
delay((SnackbarThrottleMs - elapsed))
}
}
@@ -1,11 +1,14 @@
package com.v2ray.ang.ui.compose
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -15,6 +18,7 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.v2ray.ang.AppConfig
@@ -122,14 +126,26 @@ object ThemeManager {
)
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
private val _dynamicColorEnabled = MutableStateFlow(
MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_COLOR, true)
)
val dynamicColorEnabled: StateFlow<Boolean> = _dynamicColorEnabled.asStateFlow()
fun setThemeMode(mode: String) {
MmkvManager.encodeSettings(AppConfig.PREF_UI_MODE_NIGHT, mode)
_themeMode.value = mode
}
fun setDynamicColorEnabled(enabled: Boolean) {
MmkvManager.encodeSettings(AppConfig.PREF_DYNAMIC_COLOR, enabled)
_dynamicColorEnabled.value = enabled
}
fun refresh() {
_themeMode.value =
MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0") ?: "0"
_dynamicColorEnabled.value =
MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_COLOR, true)
}
}
@@ -150,7 +166,16 @@ fun AppTheme(
darkTheme: Boolean = resolveDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColor else LightColor
val dynamicColor by ThemeManager.dynamicColorEnabled.collectAsState()
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColor
else -> LightColor
}
val snackbarController = rememberAppSnackbarController()
val view = LocalView.current
@@ -8,12 +8,14 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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
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
@@ -21,7 +23,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.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -37,12 +38,15 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.ui.base.BaseComponentActivity
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.verticalScrollbar
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -91,8 +95,9 @@ class LogcatActivity : BaseComponentActivity() {
uri to logFile.name
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to share Logcat", e)
withContext(Dispatchers.Main) {
toast(e.localizedMessage ?: e.toString())
toastError(R.string.toast_failure)
}
return@launch
}
@@ -127,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("") }
@@ -136,7 +141,7 @@ fun LogcatScreen(
val listState = rememberLazyListState()
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = stringResource(R.string.title_logcat),
@@ -159,20 +164,20 @@ fun LogcatScreen(
IconButton(onClick = { showSearch = true }) {
Icon(
painterResource(R.drawable.ic_search_24dp),
contentDescription = "filter"
contentDescription = stringResource(R.string.acc_search)
)
}
}
IconButton(onClick = { viewModel.copyLogcat() }) {
Icon(
painterResource(R.drawable.ic_copy),
contentDescription = stringResource(R.string.logcat_copy)
contentDescription = stringResource(R.string.acc_copy_log)
)
}
IconButton(onClick = { onShareLogcat() }) {
Icon(
painterResource(R.drawable.ic_share_24dp),
contentDescription = stringResource(R.string.logcat_share)
contentDescription = stringResource(R.string.acc_share_log)
)
}
IconButton(onClick = {
@@ -180,19 +185,20 @@ fun LogcatScreen(
}) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
contentDescription = stringResource(R.string.logcat_clear)
contentDescription = stringResource(R.string.acc_clear_log)
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
viewModel.loadLogcat()
}) {
FloatingActionButton(
onClick = { viewModel.loadLogcat() },
modifier = Modifier.navigationBarsPadding()
) {
Icon(
painterResource(R.drawable.ic_restore_24dp),
contentDescription = stringResource(R.string.pull_down_to_refresh)
contentDescription = stringResource(R.string.acc_refresh)
)
}
}
@@ -206,10 +212,11 @@ fun LogcatScreen(
state = listState,
modifier = Modifier
.fillMaxSize()
.verticalScrollbar(listState)
.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()
}
@@ -5,6 +5,7 @@ import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.runtime.Composable
@@ -74,10 +75,10 @@ class MainActivity : HelperBaseComponentActivity() {
val restartService = data.getBooleanExtra(
ProfileEditorResult.EXTRA_RESTART_SERVICE, false
)
val selectedProfileSaved = action == ProfileEditorResult.ACTION_SAVED &&
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
mainViewModel.onAction(MainAction.RefreshGroups)
if (restartService && mainViewModel.uiState.value.isRunning) {
restartV2Ray()
}
if (restartService || selectedProfileSaved) LauncherManager.restartService(this)
}
private val settingsActivityLauncher =
@@ -86,7 +87,7 @@ class MainActivity : HelperBaseComponentActivity() {
val refreshGroups = SettingsChangeManager.consumeSetupGroupTab()
mainViewModel.refreshUiSettings()
if (refreshGroups) mainViewModel.onAction(MainAction.RefreshGroups)
if (restartService && mainViewModel.uiState.value.isRunning) restartV2Ray()
if (restartService) LauncherManager.restartService(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -98,6 +99,7 @@ class MainActivity : HelperBaseComponentActivity() {
@Composable
override fun ScreenContent() {
BackHandler { moveTaskToBack(false) }
MainScreen(
mainViewModel = mainViewModel,
onAction = { action ->
@@ -108,7 +110,7 @@ class MainActivity : HelperBaseComponentActivity() {
MainAction.ImportClipboard -> importClipboard()
MainAction.ImportConfigLocal -> importConfigLocal()
is MainAction.ImportManually -> importManually(action.type)
MainAction.RestartService -> restartV2Ray()
MainAction.RestartService -> LauncherManager.restartServiceOrStart(this, ::requestServiceStart)
MainAction.LocateSelectedServer -> mainViewModel.triggerLocateSelectedServer()
is MainAction.SelectServer -> setSelectServer(action.guid)
is MainAction.EditServer -> editServer(action.guid, action.profile)
@@ -134,26 +136,24 @@ class MainActivity : HelperBaseComponentActivity() {
}
}
private fun navigateTo(destination: String) {
private fun navigateTo(destination: MainDestination) {
val intent = when (destination) {
"sub_setting" -> Intent(this, SubSettingActivity::class.java)
"per_app_proxy" -> Intent(this, PerAppProxyActivity::class.java)
"routing_setting" -> Intent(this, RoutingSettingActivity::class.java)
"user_asset" -> Intent(this, UserAssetActivity::class.java)
"settings" -> Intent(this, SettingsActivity::class.java)
"logcat" -> Intent(this, LogcatActivity::class.java)
"check_update" -> Intent(this, CheckUpdateActivity::class.java)
"backup_restore" -> Intent(this, BackupActivity::class.java)
"about" -> Intent(this, AboutActivity::class.java)
"promotion" -> {
MainDestination.Subscriptions -> Intent(this, SubSettingActivity::class.java)
MainDestination.PerAppProxy -> Intent(this, PerAppProxyActivity::class.java)
MainDestination.Routing -> Intent(this, RoutingSettingActivity::class.java)
MainDestination.UserAssets -> Intent(this, UserAssetActivity::class.java)
MainDestination.Settings -> Intent(this, SettingsActivity::class.java)
MainDestination.Logcat -> Intent(this, LogcatActivity::class.java)
MainDestination.CheckUpdate -> Intent(this, CheckUpdateActivity::class.java)
MainDestination.BackupRestore -> Intent(this, BackupActivity::class.java)
MainDestination.About -> Intent(this, AboutActivity::class.java)
MainDestination.Promotion -> {
Utils.openUri(
this,
"${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}"
)
return
}
else -> return
}
settingsActivityLauncher.launch(intent)
}
@@ -161,14 +161,20 @@ class MainActivity : HelperBaseComponentActivity() {
private fun handleFabAction() {
if (mainViewModel.uiState.value.isRunning) {
LauncherManager.stopService(this)
} else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this)
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
} else {
startV2Ray()
requestServiceStart()
}
}
private fun requestServiceStart() {
if (!SettingsManager.isVpnMode()) {
startV2Ray()
return
}
val intent = VpnService.prepare(this)
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
}
private fun handleLayoutTestClick() {
if (mainViewModel.uiState.value.isRunning) {
mainViewModel.testCurrentServerRealPing()
@@ -188,14 +194,6 @@ class MainActivity : HelperBaseComponentActivity() {
LauncherManager.startService(this)
}
private fun restartV2Ray() {
if (mainViewModel.uiState.value.isRunning) LauncherManager.stopService(this)
lifecycleScope.launch {
kotlinx.coroutines.delay(500)
startV2Ray()
}
}
private fun importManually(createConfigType: Int) {
val intent = when (createConfigType) {
EConfigType.POLICYGROUP.value -> Intent(this, ServerGroupActivity::class.java)
@@ -275,12 +273,12 @@ class MainActivity : HelperBaseComponentActivity() {
val selected = mainViewModel.uiState.value.selectedGuid
if (guid != selected) {
mainViewModel.updateSelectedGuid(guid)
if (mainViewModel.uiState.value.isRunning) restartV2Ray()
LauncherManager.restartService(this)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
if (keyCode == KeyEvent.KEYCODE_BUTTON_B) {
moveTaskToBack(false)
return true
}
@@ -1,5 +1,6 @@
package com.v2ray.ang.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -17,13 +18,15 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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
@@ -39,26 +42,29 @@ fun MainBottomBar(
onAction: (MainAction) -> Unit
) {
Box(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = { onAction(MainAction.TestCurrentServer) })
.windowInsetsPadding(WindowInsets.navigationBars)
) {
AppDivider()
Surface(
Row(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.navigationBars)
.height(64.dp)
.clickable(onClick = { onAction(MainAction.TestCurrentServer) }),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = displayText, style = MaterialTheme.typography.bodyMedium)
}
Text(
text = displayText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.semantics {
contentDescription = displayText
}
)
}
}
FloatingActionButton(
@@ -75,7 +81,9 @@ fun MainBottomBar(
Icon(
painter = if (isRunning) painterResource(R.drawable.ic_stop_24dp)
else painterResource(R.drawable.ic_play_24dp),
contentDescription = if (isRunning) "Stop" else "Start",
contentDescription = stringResource(
if (isRunning) R.string.acc_stop else R.string.acc_start
),
tint = Color.White,
modifier = Modifier.size(24.dp)
)
@@ -1,8 +1,18 @@
package com.v2ray.ang.ui.main
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.LocateTarget
/** Locale-neutral state formatted only when it reaches the main UI. */
sealed interface MainStatus {
data object Disconnected : MainStatus
data object Connected : MainStatus
data object Testing : MainStatus
data class TestProgress(val progress: String) : MainStatus
data class ConnectionTest(val result: ConnectionTestResult) : MainStatus
}
/**
* Main UI state
*/
@@ -12,7 +22,7 @@ data class MainUiState(
val selectedGuid: String? = null,
val isRunning: Boolean = false,
val isTesting: Boolean = false,
val statusText: String = "",
val status: MainStatus = MainStatus.Disconnected,
val locateTarget: LocateTarget? = null,
val confirmRemove: Boolean = false,
val doubleColumnDisplay: Boolean = false,
@@ -56,5 +66,5 @@ sealed interface MainAction {
data class ImportBatchConfig(val configText: String) : MainAction
data class LocateHandled(val target: LocateTarget) : MainAction
data object LocateHandled : MainAction
}
@@ -1,62 +1,86 @@
package com.v2ray.ang.ui.main
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DrawerState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
import com.v2ray.ang.ui.compose.AppDivider
import com.v2ray.ang.ui.compose.LocalDarkTheme
import com.v2ray.ang.ui.compose.verticalScrollbar
enum class MainDestination(@DrawableRes val iconRes: Int, @StringRes val labelRes: Int) {
Subscriptions(R.drawable.ic_subscriptions_24dp, R.string.title_sub_setting),
PerAppProxy(R.drawable.ic_per_apps_24dp, R.string.per_app_proxy_settings),
Routing(R.drawable.ic_routing_24dp, R.string.routing_settings_title),
UserAssets(R.drawable.ic_file_24dp, R.string.title_user_asset_setting),
Settings(R.drawable.ic_settings_24dp, R.string.title_settings),
Promotion(R.drawable.ic_promotion_24dp, R.string.title_pref_promotion),
Logcat(R.drawable.ic_logcat_24dp, R.string.title_logcat),
CheckUpdate(R.drawable.ic_check_update_24dp, R.string.update_check_for_update),
BackupRestore(R.drawable.ic_restore_24dp, R.string.title_configuration_backup_restore),
About(R.drawable.ic_about_24dp, R.string.title_about)
}
private val primaryDrawerItems = listOf(
MainDestination.Subscriptions,
MainDestination.PerAppProxy,
MainDestination.Routing,
MainDestination.UserAssets,
MainDestination.Settings
)
private val drawerItems = primaryDrawerItems + listOf(
MainDestination.Promotion,
MainDestination.Logcat,
MainDestination.CheckUpdate,
MainDestination.BackupRestore,
MainDestination.About
)
@Composable
fun MainDrawerContent(onNavigate: (String) -> Unit) {
fun MainDrawerContent(drawerState: DrawerState, onNavigate: (MainDestination) -> Unit) {
val drawerScrollState = rememberScrollState()
ModalDrawerSheet(
modifier = Modifier
.fillMaxWidth(0.75f)
.navigationBarsPadding(),
drawerState = drawerState,
modifier = Modifier.fillMaxWidth(0.75f),
drawerContainerColor = MaterialTheme.colorScheme.surface
) {
Column(
modifier = Modifier
.verticalScroll(drawerScrollState)
.verticalScrollbar(drawerScrollState)
.padding(bottom = 80.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
.height(180.dp)
) {
Column(
modifier = Modifier
@@ -65,92 +89,34 @@ fun MainDrawerContent(onNavigate: (String) -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val isDarkTheme = LocalDarkTheme.current
Image(
painter = painterResource(R.mipmap.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(120.dp),
colorFilter = if (isDarkTheme) {
ColorFilter.tint(Color.White, BlendMode.SrcIn)
} else {
null
}
)
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily(Font(R.font.montserrat_thin)),
fontWeight = FontWeight.Thin
),
textAlign = TextAlign.Center
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
}
DrawerMenuGroup(
items = listOf(
DrawerMenuItemData(R.drawable.ic_subscriptions_24dp, R.string.title_sub_setting, "sub_setting"),
DrawerMenuItemData(R.drawable.ic_per_apps_24dp, R.string.per_app_proxy_settings, "per_app_proxy"),
DrawerMenuItemData(R.drawable.ic_routing_24dp, R.string.routing_settings_title, "routing_setting"),
DrawerMenuItemData(R.drawable.ic_file_24dp, R.string.title_user_asset_setting, "user_asset"),
DrawerMenuItemData(R.drawable.ic_settings_24dp, R.string.title_settings, "settings")
),
onNavigate = onNavigate
)
AppDivider()
DrawerMenuGroup(
items = listOf(
DrawerMenuItemData(R.drawable.ic_promotion_24dp, R.string.title_pref_promotion, "promotion"),
DrawerMenuItemData(R.drawable.ic_logcat_24dp, R.string.title_logcat, "logcat"),
DrawerMenuItemData(R.drawable.ic_check_update_24dp, R.string.update_check_for_update, "check_update"),
DrawerMenuItemData(R.drawable.ic_restore_24dp, R.string.title_configuration_backup_restore, "backup_restore"),
DrawerMenuItemData(R.drawable.ic_about_24dp, R.string.title_about, "about")
),
onNavigate = onNavigate
)
drawerItems.forEachIndexed { index, item ->
if (index == primaryDrawerItems.size) AppDivider()
NavigationDrawerItem(
label = { Text(stringResource(item.labelRes)) },
selected = false,
onClick = { onNavigate(item) },
icon = { Icon(painterResource(item.iconRes), contentDescription = null) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
}
}
data class DrawerMenuItemData(
val iconRes: Int,
val labelRes: Int,
val route: String
)
@Composable
private fun DrawerMenuGroup(
items: List<DrawerMenuItemData>,
onNavigate: (String) -> Unit
) {
items.forEach { item ->
DrawerMenuItem(
icon = painterResource(item.iconRes),
label = stringResource(item.labelRes),
onClick = { onNavigate(item.route) }
)
}
}
@Composable
fun DrawerMenuItem(
icon: Painter,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
selected: Boolean = false
) {
Row(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(onClick = onClick)
.background(
if (selected) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)
else Color.Transparent
)
.padding(horizontal = 16.dp, vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@@ -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,14 +13,12 @@ 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
import com.v2ray.ang.ui.compose.colorFabActive
import kotlinx.coroutines.flow.StateFlow
@Composable
@@ -29,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,
color = colorFabActive
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) }
)
}
@@ -65,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,
@@ -1,16 +1,44 @@
package com.v2ray.ang.ui.main
import androidx.annotation.StringRes
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.isComplexType
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
import com.v2ray.ang.ui.compose.SelectListDialog
private enum class ImportMenuAction(@StringRes val labelRes: Int, val action: MainAction) {
QRCode(R.string.menu_item_import_config_qrcode, MainAction.ImportQRcode),
Clipboard(R.string.menu_item_import_config_clipboard, MainAction.ImportClipboard),
LocalFile(R.string.menu_item_import_config_local, MainAction.ImportConfigLocal),
PolicyGroup(R.string.menu_item_import_config_policy_group, MainAction.ImportManually(EConfigType.POLICYGROUP.value)),
ProxyChain(R.string.menu_item_import_config_proxy_chain, MainAction.ImportManually(EConfigType.PROXYCHAIN.value)),
Vmess(R.string.menu_item_import_config_manually_vmess, MainAction.ImportManually(EConfigType.VMESS.value)),
Vless(R.string.menu_item_import_config_manually_vless, MainAction.ImportManually(EConfigType.VLESS.value)),
Shadowsocks(R.string.menu_item_import_config_manually_ss, MainAction.ImportManually(EConfigType.SHADOWSOCKS.value)),
Socks(R.string.menu_item_import_config_manually_socks, MainAction.ImportManually(EConfigType.SOCKS.value)),
Http(R.string.menu_item_import_config_manually_http, MainAction.ImportManually(EConfigType.HTTP.value)),
Trojan(R.string.menu_item_import_config_manually_trojan, MainAction.ImportManually(EConfigType.TROJAN.value)),
WireGuard(R.string.menu_item_import_config_manually_wireguard, MainAction.ImportManually(EConfigType.WIREGUARD.value)),
Hysteria2(R.string.menu_item_import_config_manually_hysteria2, MainAction.ImportManually(EConfigType.HYSTERIA2.value))
}
enum class MainMoreMenuAction(@StringRes val labelRes: Int) {
RestartService(R.string.title_service_restart),
DeleteAll(R.string.title_del_all_config),
DeleteDuplicate(R.string.title_del_duplicate_config),
DeleteInvalid(R.string.title_del_invalid_config),
ExportAll(R.string.title_export_all),
LocateSelected(R.string.title_locate_selected_config),
SortByTestResults(R.string.title_sort_by_test_results),
TestAll(R.string.title_ping_all_server),
TestAllRealPing(R.string.title_real_ping_all_server),
UpdateSubscriptions(R.string.title_sub_update)
}
internal enum class ServerMenuAction(
@StringRes val labelRes: Int,
val isShareAction: Boolean,
@@ -31,111 +59,18 @@ internal fun serverMenuActions(
}
@Composable
fun ImportMenuContent(
onAction: (MainAction) -> Unit
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_qrcode)) },
onClick = { onAction(MainAction.ImportQRcode) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_clipboard)) },
onClick = { onAction(MainAction.ImportClipboard) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_local)) },
onClick = { onAction(MainAction.ImportConfigLocal) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_policy_group)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.POLICYGROUP.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_proxy_chain)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.PROXYCHAIN.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_manually_vmess)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.VMESS.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_manually_vless)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.VLESS.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_manually_ss)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.SHADOWSOCKS.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_manually_socks)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.SOCKS.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_manually_http)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.HTTP.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_manually_trojan)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.TROJAN.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_manually_wireguard)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.WIREGUARD.value)) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_config_manually_hysteria2)) },
onClick = { onAction(MainAction.ImportManually(EConfigType.HYSTERIA2.value)) }
)
}
fun ImportMenuContent(onAction: (MainAction) -> Unit) = AppDropdownMenuItems(
items = ImportMenuAction.entries,
labelRes = { it.labelRes },
onSelected = { onAction(it.action) }
)
@Composable
fun MoreMenuContent(
onAction: (MainAction) -> Unit,
onDelAllConfig: () -> Unit,
onDelDuplicateConfig: () -> Unit,
onDelInvalidConfig: () -> Unit
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.title_service_restart)) },
onClick = { onAction(MainAction.RestartService) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_del_all_config)) },
onClick = onDelAllConfig
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_del_duplicate_config)) },
onClick = onDelDuplicateConfig
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_del_invalid_config)) },
onClick = onDelInvalidConfig
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_export_all)) },
onClick = { onAction(MainAction.ExportAll) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_locate_selected_config)) },
onClick = { onAction(MainAction.LocateSelectedServer) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_sort_by_test_results)) },
onClick = { onAction(MainAction.SortByTestResults) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_ping_all_server)) },
onClick = { onAction(MainAction.TestAllServers) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_real_ping_all_server)) },
onClick = { onAction(MainAction.TestRealAllServers) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.title_sub_update)) },
onClick = { onAction(MainAction.UpdateSubscriptions) }
)
}
fun MoreMenuContent(onSelected: (MainMoreMenuAction) -> Unit) = AppDropdownMenuItems(
items = MainMoreMenuAction.entries,
labelRes = { it.labelRes },
onSelected = onSelected
)
@Composable
fun ShareMethodDialog(
@@ -151,10 +86,11 @@ fun ShareMethodDialog(
includeManagementActions = more,
)
SelectListDialog(
options = menuActions.map { stringResource(it.labelRes) },
onSelected = { index, _ ->
options = menuActions,
optionText = { stringResource(it.labelRes) },
onSelected = { action ->
onDismiss()
when (menuActions[index]) {
when (action) {
ServerMenuAction.ShareQRCode -> onAction(MainAction.ShareQRCode(guid))
ServerMenuAction.ShareClipboard -> onAction(MainAction.ShareClipboard(guid))
ServerMenuAction.ShareFullContent -> onAction(MainAction.ShareFullContent(guid))
@@ -8,13 +8,16 @@ import androidx.core.content.ContextCompat
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.SubscriptionUpdateResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SubscriptionUpdater
@@ -31,6 +34,9 @@ class MainRepository(
private val app: AngApplication
) : MainDataSource {
private val localizedContext: Context
get() = AppLocaleManager.localizedContext(app)
private val closed = AtomicBoolean(false)
private val _mainServiceEvent = MutableSharedFlow<MainServiceEvent>(
@@ -48,14 +54,12 @@ class MainRepository(
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(
safeIntent.getStringExtra("content").orEmpty()
)
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure
AppConfig.MSG_STATE_STOP_SUCCESS -> MainServiceEvent.StateStopSuccess
AppConfig.MSG_MEASURE_DELAY_SUCCESS -> MainServiceEvent.MeasureDelaySuccess(
safeIntent.getStringExtra("content").orEmpty()
)
AppConfig.MSG_MEASURE_DELAY_RESULT -> safeIntent
.serializable<ConnectionTestResult>("content")
?.let { MainServiceEvent.MeasureDelayResult(it) }
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> MainServiceEvent.MeasureConfigSuccess
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> MainServiceEvent.MeasureConfigNotify(
@@ -116,9 +120,10 @@ class MainRepository(
override fun isGroupAllDisplayEnabled(): Boolean =
MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)
override fun getString(resId: Int): String = app.getString(resId)
override fun getString(resId: Int): String = localizedContext.getString(resId)
override fun getString(resId: Int, vararg formatArgs: Any): String = app.getString(resId, *formatArgs)
override fun getString(resId: Int, vararg formatArgs: Any): String =
localizedContext.getString(resId, *formatArgs)
override fun getSubscriptions(): List<SubscriptionCache> {
val result = mutableListOf<SubscriptionCache>()
@@ -126,7 +131,7 @@ class MainRepository(
result += SubscriptionCache(
guid = "",
subscription = SubscriptionItem().apply {
remarks = app.getString(R.string.filter_config_all)
remarks = localizedContext.getString(R.string.filter_config_all)
}
)
}
@@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
@@ -31,22 +30,20 @@ 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 kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
mainViewModel: MainViewModel,
onAction: (MainAction) -> Unit,
onNavigate: (String) -> Unit,
onNavigate: (MainDestination) -> Unit,
) {
val uiState by mainViewModel.uiState.collectAsStateWithLifecycle()
val groups = uiState.groups
val isLoading by mainViewModel.isLoading.collectAsStateWithLifecycle()
val isRunning = uiState.isRunning
val displayText = uiState.statusText
val displayText = mainViewModel.formatStatus(uiState.status)
val selectedGuid = uiState.selectedGuid
val doubleColumnDisplay = uiState.doubleColumnDisplay
val confirmRemove = uiState.confirmRemove
@@ -75,16 +72,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 }
@@ -95,68 +88,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(16L)
}
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(32L)
locateInProgress = false
mainViewModel.onAction(MainAction.LocateHandled(target))
}
}
MainDialogs(
showDelAllConfirm = showDelAllConfirm,
onDismissDelAll = { showDelAllConfirm = false },
@@ -191,6 +134,7 @@ fun MainScreen(
drawerState = drawerState,
drawerContent = {
MainDrawerContent(
drawerState = drawerState,
onNavigate = { route ->
scope.launch { drawerState.close() }
onNavigate(route)
@@ -217,9 +161,20 @@ fun MainScreen(
onSearchToggle = { show: Boolean -> showSearch = show },
onMenuClick = { scope.launch { drawerState.open() } },
onAction = onAction,
onDelAllConfig = { showDelAllConfirm = true },
onDelDuplicateConfig = { showDelDuplicateConfirm = true },
onDelInvalidConfig = { showDelInvalidConfirm = true }
onMoreMenuAction = { action ->
when (action) {
MainMoreMenuAction.RestartService -> onAction(MainAction.RestartService)
MainMoreMenuAction.DeleteAll -> showDelAllConfirm = true
MainMoreMenuAction.DeleteDuplicate -> showDelDuplicateConfirm = true
MainMoreMenuAction.DeleteInvalid -> showDelInvalidConfirm = true
MainMoreMenuAction.ExportAll -> onAction(MainAction.ExportAll)
MainMoreMenuAction.LocateSelected -> onAction(MainAction.LocateSelectedServer)
MainMoreMenuAction.SortByTestResults -> onAction(MainAction.SortByTestResults)
MainMoreMenuAction.TestAll -> onAction(MainAction.TestAllServers)
MainMoreMenuAction.TestAllRealPing -> onAction(MainAction.TestRealAllServers)
MainMoreMenuAction.UpdateSubscriptions -> onAction(MainAction.UpdateSubscriptions)
}
}
)
},
bottomBar = {
@@ -269,8 +224,8 @@ fun MainScreen(
groupId = group.id,
mainViewModel = mainViewModel,
selectedGuid = selectedGuid,
locateTarget = uiState.locateTarget,
doubleColumnDisplay = doubleColumnDisplay,
confirmRemove = confirmRemove,
searchQuery = searchQuery,
lazyListStates = lazyListStates,
lazyGridStates = lazyGridStates,
@@ -30,12 +30,16 @@ 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
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.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
@@ -43,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
@@ -66,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,47 +78,64 @@ fun GroupPagerPage(
onRemoveServer: (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) -> 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
) {
@@ -132,6 +149,8 @@ private fun ServerListPage(
}
} else null
LocateTargetEffect(locateTarget, rows, gridState, onLocateHandled)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
state = gridState,
@@ -140,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,
@@ -179,6 +193,8 @@ private fun ServerListPage(
}
} else null
LocateTargetEffect(locateTarget, rows, listState, onLocateHandled)
LazyColumn(
state = listState,
modifier = Modifier
@@ -186,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()
}
@@ -228,100 +234,96 @@ 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),
testResult = serverCache.testDelayString,
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),
testResult = serverCache.testDelayString,
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,
testResult: 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 (row.testDelayMillis == 0L) {
""
} else {
stringResource(R.string.server_test_delay_value, row.testDelayMillis)
}
val selectedStateDescription = if (isSelected) {
stringResource(R.string.acc_selected_server)
} else {
null
}
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.clickable(onClick = onClick)
.then(dragModifier)
.semantics {
if (selectedStateDescription != null) {
stateDescription = selectedStateDescription
}
}
.clickable { actions.select(row.guid) }
) {
Box(
Modifier
@@ -348,58 +350,69 @@ 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)) {
Icon(painterResource(R.drawable.ic_more_vert_24dp), null, Modifier.size(24.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),
Modifier.size(24.dp)
)
}
} else {
IconButton(onClick = onShare, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_share_24dp), null, Modifier.size(24.dp)) }
IconButton(onClick = onEdit, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_edit_24dp), null, Modifier.size(24.dp)) }
IconButton(onClick = onRemove, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_delete_24dp), null, Modifier.size(24.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 = { 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 = { actions.remove(row.guid) }, Modifier.size(36.dp)) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
stringResource(R.string.acc_delete),
Modifier.size(24.dp)
)
}
}
}
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,12 +1,14 @@
package com.v2ray.ang.ui.main
import com.v2ray.ang.dto.ConnectionTestResult
sealed class MainServiceEvent {
data object StateRunning : MainServiceEvent()
data object StateNotRunning : MainServiceEvent()
data object StateStartSuccess : MainServiceEvent()
data class StateStartFailure(val errorMessage: String) : MainServiceEvent()
data object StateStartFailure : MainServiceEvent()
data object StateStopSuccess : MainServiceEvent()
data class MeasureDelaySuccess(val content: String) : 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()
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -28,7 +27,6 @@ import com.v2ray.ang.R
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.verticalScrollbar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainTopBar(
isLoading: Boolean,
@@ -39,9 +37,7 @@ fun MainTopBar(
onSearchToggle: (Boolean) -> Unit,
onMenuClick: () -> Unit,
onAction: (MainAction) -> Unit,
onDelAllConfig: () -> Unit,
onDelDuplicateConfig: () -> Unit,
onDelInvalidConfig: () -> Unit
onMoreMenuAction: (MainMoreMenuAction) -> Unit
) {
var showImportMenu by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
@@ -63,23 +59,23 @@ fun MainTopBar(
navigationIcon = {
if (showSearch) {
IconButton(onClick = onSearchClose) {
Icon(painterResource(R.drawable.ic_arrow_back_24dp), contentDescription = "Back")
Icon(painterResource(R.drawable.ic_arrow_back_24dp), contentDescription = stringResource(R.string.acc_back))
}
} else {
IconButton(onClick = onMenuClick) {
Icon(painterResource(R.drawable.ic_menu_24dp), contentDescription = "Menu")
Icon(painterResource(R.drawable.ic_menu_24dp), contentDescription = stringResource(R.string.acc_open_menu))
}
}
},
actions = {
if (!showSearch) {
IconButton(onClick = { onSearchToggle(true) }) {
Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = "filter")
Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = stringResource(R.string.acc_search))
}
}
Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
IconButton(onClick = { showImportMenu = true }) {
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = "Add")
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = stringResource(R.string.acc_add))
}
DropdownMenu(
expanded = showImportMenu,
@@ -100,7 +96,7 @@ fun MainTopBar(
}
Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
IconButton(onClick = { showMenu = true }) {
Icon(painterResource(R.drawable.ic_more_vert_24dp), contentDescription = "More")
Icon(painterResource(R.drawable.ic_more_vert_24dp), contentDescription = stringResource(R.string.acc_more))
}
DropdownMenu(
expanded = showMenu,
@@ -111,15 +107,10 @@ fun MainTopBar(
.heightIn(max = maxMenuHeight)
.verticalScrollbar(moreMenuScrollState)
) {
MoreMenuContent(
onAction = { action ->
showMenu = false
onAction(action)
},
onDelAllConfig = { showMenu = false; onDelAllConfig() },
onDelDuplicateConfig = { showMenu = false; onDelDuplicateConfig() },
onDelInvalidConfig = { showMenu = false; onDelInvalidConfig() }
)
MoreMenuContent { action ->
showMenu = false
onMoreMenuAction(action)
}
}
}
}
@@ -6,12 +6,14 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
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.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,11 +25,13 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.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
@@ -45,15 +49,11 @@ class MainViewModel(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
private val preloadDispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1)
private val disconnectedText: String = dataSource.getString(R.string.connection_not_connected)
private val connectedText: String = dataSource.getString(R.string.connection_connected)
// ---------- UI state ----------
private val _uiState = MutableStateFlow(
MainUiState(
selectedGroupId = dataSource.getSelectedSubscriptionId(),
selectedGuid = dataSource.getSelectServer(),
statusText = disconnectedText,
confirmRemove = dataSource.getConfirmRemove(),
doubleColumnDisplay = dataSource.getDoubleColumnDisplay()
)
@@ -68,7 +68,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>()
@@ -105,19 +106,14 @@ class MainViewModel(
updateRunningState(true)
}
is MainServiceEvent.StateStartFailure -> {
val error = event.errorMessage
if (error.isNotBlank()) {
toastError(error)
} else {
toastError(R.string.toast_services_failure)
}
MainServiceEvent.StateStartFailure -> {
toastError(R.string.toast_services_failure)
updateRunningState(false)
}
MainServiceEvent.StateStopSuccess -> updateRunningState(false)
is MainServiceEvent.MeasureDelaySuccess -> {
_uiState.update { it.copy(statusText = event.content) }
is MainServiceEvent.MeasureDelayResult -> {
_uiState.update { it.copy(status = MainStatus.ConnectionTest(event.result)) }
}
MainServiceEvent.MeasureConfigSuccess -> {
@@ -129,34 +125,67 @@ class MainViewModel(
}
is MainServiceEvent.MeasureConfigNotify -> {
_uiState.update {
it.copy(
statusText = dataSource.getString(
R.string.connection_runing_task_left,
event.progress
)
)
}
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
}
is MainServiceEvent.MeasureConfigFinish -> {
if (event.finishedCount == "0") {
onTestsFinished()
}
onTestsFinished()
}
}
}
internal fun formatStatus(status: MainStatus): String = when (status) {
MainStatus.Disconnected -> dataSource.getString(R.string.connection_not_connected)
MainStatus.Connected -> dataSource.getString(R.string.connection_connected)
MainStatus.Testing -> dataSource.getString(R.string.connection_test_testing)
is MainStatus.TestProgress -> dataSource.getString(
R.string.connection_running_task_left,
status.progress
)
is MainStatus.ConnectionTest -> formatConnectionTestResult(status.result)
}
private fun formatConnectionTestResult(result: ConnectionTestResult): String {
val status = if (result.delayMillis >= 0) {
val delay = dataSource.getString(R.string.server_test_delay_value, result.delayMillis)
dataSource.getString(R.string.connection_test_available, delay)
} else {
val detail = result.errorMessage.ifBlank {
dataSource.getString(R.string.connection_test_empty_message)
}
dataSource.getString(R.string.connection_test_error, detail)
}
if (result.delayMillis < 0 || (result.country == null && result.ipAddress == null)) {
return status
}
val unknown = dataSource.getString(R.string.value_unknown)
return "$status\n(${result.country ?: unknown}) ${result.ipAddress ?: unknown}"
}
// ---------- 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) {
@@ -177,7 +206,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) }
@@ -208,7 +237,7 @@ class MainViewModel(
viewModelScope.launch(preloadDispatcher) {
try {
initialPageReady.await()
delay(32L)
delay(32)
dataSource.initAssets()
dataSource.syncSubscriptions()
} catch (cancelled: CancellationException) {
@@ -237,8 +266,7 @@ class MainViewModel(
ServersCache(
guid = guid,
profile = profile.copy(),
testDelayMillis = affiliation?.testDelayMillis ?: 0L,
testDelayString = affiliation?.getTestDelayString().orEmpty()
testDelayMillis = affiliation?.testDelayMillis ?: 0L
)
}
@@ -276,7 +304,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()
@@ -321,17 +373,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() }
@@ -351,7 +404,7 @@ class MainViewModel(
preloadJob = viewModelScope.launch(preloadDispatcher) {
preloadOrder.forEach { groupId ->
ensureActive()
delay(32L)
delay(32)
val servers = loadGroup(groupId, forceRefresh)
updateGroupUi(groupId, servers)
}
@@ -411,7 +464,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))
@@ -569,7 +628,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) }
@@ -604,7 +663,7 @@ class MainViewModel(
}
order.forEachIndexed { index, groupId ->
ensureActive()
if (index > 0) delay(32L)
if (index > 0) delay(32)
updateGroupUi(groupId, loadGroup(groupId, forceRefresh = true))
}
}
@@ -615,7 +674,7 @@ class MainViewModel(
keywordFilter = keyword
filterJob?.cancel()
filterJob = viewModelScope.launch(defaultDispatcher) {
delay(300L)
delay(300)
val snapshot = cacheMutex.withLock { groupDataCache.toMap() }
ensureActive()
snapshot.forEach { (groupId, servers) ->
@@ -647,10 +706,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) {
@@ -667,7 +729,7 @@ class MainViewModel(
_uiState.update {
it.copy(
isTesting = false,
statusText = if (it.isRunning) connectedText else disconnectedText
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
)
}
}
@@ -676,25 +738,38 @@ class MainViewModel(
dataSource.cancelAllPing()
val groupId = uiState.value.selectedGroupId
val servers = currentServers()
dataSource.clearAllTestDelayResults(servers.map { it.guid })
if (servers.isEmpty()) {
_uiState.update { it.copy(isTesting = false) }
return
}
val serverGuids = servers.map { it.guid }
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
_uiState.update {
it.copy(
isTesting = true,
statusText = dataSource.getString(R.string.connection_test_testing)
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()) servers.map { it.guid } else emptyList(),
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
onlyTcp = onlyTcp
)
)
@@ -702,11 +777,7 @@ class MainViewModel(
}
fun testCurrentServerRealPing() {
_uiState.update {
it.copy(
statusText = dataSource.getString(R.string.connection_test_testing)
)
}
_uiState.update { it.copy(status = MainStatus.Testing) }
dataSource.testCurrentServerRealPing()
}
@@ -717,7 +788,7 @@ class MainViewModel(
_uiState.update {
it.copy(
isTesting = false,
statusText = if (it.isRunning) connectedText else disconnectedText
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
)
}
reloadAllGroups(_uiState.value.groups.map { it.id })
@@ -728,24 +799,21 @@ 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 ----------
@@ -753,8 +821,8 @@ class MainViewModel(
_uiState.update { state ->
state.copy(
isRunning = running,
statusText = if (!clearTestingText && state.isTesting) state.statusText
else if (running) connectedText else disconnectedText
status = if (!clearTestingText && state.isTesting) state.status
else if (running) MainStatus.Connected else MainStatus.Disconnected
)
}
}
@@ -2,11 +2,13 @@ package com.v2ray.ang.ui.perappproxy
import android.os.Bundle
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -15,13 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
@@ -42,17 +41,26 @@ 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
private enum class PerAppMenuAction(@StringRes val labelRes: Int) {
SelectAll(R.string.menu_item_select_all),
InvertSelection(R.string.menu_item_invert_selection),
SelectProxyApps(R.string.menu_item_select_proxy_app),
ImportSelection(R.string.menu_item_import_proxy_app),
ExportSelection(R.string.menu_item_export_proxy_app)
}
class PerAppProxyActivity : BaseComponentActivity() {
private val viewModel: PerAppProxyViewModel by viewModels()
@@ -79,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() },
@@ -100,7 +105,6 @@ class PerAppProxyActivity : BaseComponentActivity() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PerAppProxyScreen(
apps: List<AppInfo>,
@@ -111,7 +115,6 @@ fun PerAppProxyScreen(
onBackClick: () -> Unit,
onPerAppProxyChanged: (Boolean) -> Unit,
onBypassAppsChanged: (Boolean) -> Unit,
onInfoClick: () -> Unit,
onToggleApp: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit,
@@ -123,14 +126,16 @@ 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(searchQuery) {
LaunchedEffect(Unit) {
onSearch(searchQuery)
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = stringResource(R.string.per_app_proxy_settings),
@@ -140,9 +145,11 @@ fun PerAppProxyScreen(
searchQuery = searchQuery,
onSearchQueryChange = { query ->
searchQuery = query
onSearch(query)
},
onSearchClose = {
searchQuery = ""
onSearch("")
showSearch = false
},
searchPlaceholder = stringResource(R.string.menu_item_search),
@@ -151,7 +158,7 @@ fun PerAppProxyScreen(
IconButton(onClick = { showSearch = true }) {
Icon(
painterResource(R.drawable.ic_search_24dp),
contentDescription = stringResource(R.string.menu_item_search)
contentDescription = stringResource(R.string.acc_search)
)
}
}
@@ -159,7 +166,7 @@ fun PerAppProxyScreen(
IconButton(onClick = { showMenu = true }) {
Icon(
painterResource(R.drawable.ic_more_vert_24dp),
contentDescription = null
contentDescription = stringResource(R.string.acc_more)
)
}
DropdownMenu(
@@ -167,26 +174,16 @@ fun PerAppProxyScreen(
onDismissRequest = { showMenu = false },
containerColor = MaterialTheme.colorScheme.surface
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_select_all)) },
onClick = { showMenu = false; onSelectAll() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_invert_selection)) },
onClick = { showMenu = false; onInvertSelection() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_select_proxy_app)) },
onClick = { showMenu = false; onSelectProxyAuto() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_proxy_app)) },
onClick = { showMenu = false; onImportProxyApp() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_export_proxy_app)) },
onClick = { showMenu = false; onExportProxyApp() }
)
AppDropdownMenuItems(PerAppMenuAction.entries, { it.labelRes }) { action ->
showMenu = false
when (action) {
PerAppMenuAction.SelectAll -> onSelectAll()
PerAppMenuAction.InvertSelection -> onInvertSelection()
PerAppMenuAction.SelectProxyApps -> onSelectProxyAuto()
PerAppMenuAction.ImportSelection -> onImportProxyApp()
PerAppMenuAction.ExportSelection -> onExportProxyApp()
}
}
}
}
}
@@ -225,7 +222,7 @@ fun PerAppProxyScreen(
onCheckedChange = onPerAppProxyChanged,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
checkedTrackColor = colorFabActive
checkedTrackColor = MaterialTheme.colorScheme.secondary
)
)
}
@@ -246,14 +243,14 @@ fun PerAppProxyScreen(
onCheckedChange = onBypassAppsChanged,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
checkedTrackColor = colorFabActive
checkedTrackColor = MaterialTheme.colorScheme.secondary
)
)
}
IconButton(onClick = onInfoClick) {
Icon(
painter = painterResource(R.drawable.ic_about_24dp),
contentDescription = stringResource(R.string.summary_pref_per_app_proxy),
contentDescription = stringResource(R.string.acc_per_app_proxy_information),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -265,14 +262,15 @@ fun PerAppProxyScreen(
state = listState,
modifier = Modifier
.fillMaxSize()
.verticalScrollbar(listState)
.verticalScrollbar(listState),
contentPadding = NavigationBarsBottomPadding()
) {
items(items = apps, key = { it.packageName }) { app ->
val checked = blacklist.contains(app.packageName)
AppListItem(
appName = app.appName,
packageName = app.packageName,
icon = app.appIcon,
icon = null,
checked = checked,
onCheckedChange = { onToggleApp(app.packageName) }
)
@@ -281,4 +279,13 @@ fun PerAppProxyScreen(
}
}
}
if (showInfoDialog) {
ConfirmDialog(
message = stringResource(R.string.summary_pref_per_app_proxy),
dismissText = null,
onConfirm = {},
onDismiss = { showInfoDialog = false },
)
}
}
@@ -8,11 +8,13 @@ import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.AppSelection
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -46,64 +48,30 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
// Cached full list for filtering
private var appsAll: List<AppInfo>? = null
private var currentQuery = ""
private var isAppListLoading = false
// Blacklist operations
fun contains(packageName: String): Boolean = _blacklist.value.contains(packageName)
fun add(packageName: String) {
val newSet = _blacklist.value + packageName
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun remove(packageName: String) {
val newSet = _blacklist.value - packageName
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun toggle(packageName: String) {
if (_blacklist.value.contains(packageName)) {
remove(packageName)
val currentSelection = _blacklist.value
val newSelection = if (packageName in currentSelection) {
currentSelection - packageName
} else {
add(packageName)
}
}
fun addAll(packages: Collection<String>) {
val newSet = _blacklist.value + packages
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun removeAll(packages: Collection<String>) {
val newSet = _blacklist.value - packages.toSet()
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun clear() {
if (_blacklist.value.isNotEmpty()) {
_blacklist.value = emptySet()
saveBlacklist()
currentSelection + packageName
}
replaceBlacklist(newSelection)
SettingsChangeManager.makeRestartService()
}
private fun loadBlacklist(): Set<String> {
return MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.toSet() ?: emptySet()
}
private fun saveBlacklist() {
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, _blacklist.value.toMutableSet())
SettingsChangeManager.makeRestartService()
private fun replaceBlacklist(newBlacklist: Set<String>) {
if (newBlacklist == _blacklist.value) return
_blacklist.value = newBlacklist
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, newBlacklist.toMutableSet())
}
// Per‑app proxy switch
@@ -123,36 +91,49 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
// Load and filter apps
fun loadApps(context: Context) {
if (appsAll != null || isAppListLoading) return
val applicationContext = context.applicationContext
isAppListLoading = true
launchLoading {
try {
val apps = withContext(Dispatchers.IO) {
val list = AppManagerUtil.loadNetworkAppList(context)
val list = AppManagerUtil.loadNetworkAppList(applicationContext)
sortApps(list)
}
appsAll = apps
_displayedApps.value = apps
_displayedApps.value = applyFilter(currentQuery)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.ANG_PACKAGE, "Error loading apps", e)
} finally {
isAppListLoading = false
}
}
}
fun filterApps(query: String) {
val key = query.uppercase()
_displayedApps.value = if (key.isNotEmpty()) {
appsAll?.filter {
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
} ?: emptyList()
} else {
appsAll ?: emptyList()
currentQuery = query
_displayedApps.value = applyFilter(query)
}
private fun applyFilter(query: String): List<AppInfo> {
val apps = appsAll ?: return emptyList()
if (query.isEmpty()) return apps
return apps.filter {
it.appName.contains(query, ignoreCase = true) ||
it.packageName.contains(query, ignoreCase = true)
}
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
val selectedPackages = _blacklist.value
return apps.sortedWith { p1, p2 ->
val s1 = contains(p1.packageName)
val s2 = contains(p2.packageName)
val s1 = p1.packageName in selectedPackages
val s2 = p2.packageName in selectedPackages
when {
s1 && !s2 -> -1
!s1 && s2 -> 1
@@ -165,19 +146,26 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
// Bulk actions
fun selectAll() {
val pkgNames = _displayedApps.value.map { it.packageName }
val allSelected = pkgNames.all { contains(it) }
if (allSelected) removeAll(pkgNames) else addAll(pkgNames)
// Enable per‑app proxy after selection
val displayedApps = _displayedApps.value
val currentSelection = _blacklist.value
val allSelected = displayedApps.all { it.packageName in currentSelection }
val newSelection = currentSelection.toMutableSet().apply {
displayedApps.forEach { app ->
if (allSelected) remove(app.packageName) else add(app.packageName)
}
}
replaceBlacklist(newSelection)
enablePerAppProxyAndRestart()
}
fun invertSelection() {
_displayedApps.value.forEach { toggle(it.packageName) }
val packageNames = _displayedApps.value.map { it.packageName }
replaceBlacklist(AppSelection.invert(_blacklist.value, packageNames))
enablePerAppProxyAndRestart()
}
fun selectProxyAppAuto(context: Context) {
val applicationContext = context.applicationContext
launchLoading {
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
var content = withContext(Dispatchers.IO) {
@@ -204,7 +192,11 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
)
} ?: ""
}
val success = selectProxyApp(content, true, context)
val success = applyProxyAppList(
content = content,
context = applicationContext,
forceGoogleApps = true
)
if (success) {
enablePerAppProxyAndRestart()
}
@@ -213,42 +205,53 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
fun importProxyApp(content: String?, context: Context) {
if (content.isNullOrEmpty()) return
val success = selectProxyApp(content, false, context)
if (success) {
enablePerAppProxyAndRestart()
val applicationContext = context.applicationContext
launchLoading {
val success = applyProxyAppList(
content = content,
context = applicationContext,
forceGoogleApps = false
)
if (success) {
enablePerAppProxyAndRestart()
}
}
}
fun exportProxyApp(): String {
var result = _bypassApps.value.toString()
_blacklist.value.forEach { pkg ->
result = result + System.lineSeparator() + pkg
return buildString {
append(_bypassApps.value)
_blacklist.value.forEach { packageName ->
append(System.lineSeparator())
append(packageName)
}
}
return result
}
private fun selectProxyApp(content: String, force: Boolean, context: Context): Boolean {
try {
val proxyApps = if (content.isEmpty()) {
Utils.readTextFromAssets(context, "proxy_package_name")
} else content
if (proxyApps.isNullOrEmpty()) return false
private suspend fun applyProxyAppList(content: String, context: Context, forceGoogleApps: Boolean): Boolean {
val installedApps = appsAll ?: return false
clear()
val apps = _displayedApps.value
if (_bypassApps.value) {
apps.forEach { app ->
if (!inProxyApps(proxyApps, app.packageName, force)) {
add(app.packageName)
}
}
} else {
apps.forEach { app ->
if (inProxyApps(proxyApps, app.packageName, force)) {
add(app.packageName)
}
try {
val proxyAppList = if (content.isEmpty()) {
withContext(Dispatchers.IO) {
Utils.readTextFromAssets(context, "proxy_package_name")
}
} else content
if (proxyAppList.isNullOrEmpty()) return false
val bypassApps = _bypassApps.value
val newBlacklist = withContext(Dispatchers.Default) {
AppSelection.fromProxyList(
packageNames = installedApps.map { it.packageName },
proxyAppList = proxyAppList,
bypassApps = bypassApps,
forceGoogleApps = forceGoogleApps
)
}
replaceBlacklist(newBlacklist)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
return false
@@ -256,16 +259,8 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
return true
}
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
if (force) {
if (packageName == "com.google.android.webview") return false
if (packageName.startsWith("com.google")) return true
}
return proxyApps.indexOf(packageName) >= 0
}
private fun enablePerAppProxyAndRestart() {
setPerAppProxyEnabled(true)
SettingsChangeManager.makeRestartService()
}
}
}
@@ -6,6 +6,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.height
@@ -16,8 +17,8 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -45,6 +46,7 @@ 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.NavigationBarsSpacer
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import kotlinx.coroutines.Dispatchers
@@ -118,6 +120,7 @@ fun RoutingEditScreen(
onDelete: () -> Unit
) {
val context = LocalContext.current
val processSelectTitle = stringResource(R.string.routing_settings_process_select)
val scrollState = rememberScrollState()
var remarks by rememberSaveable { mutableStateOf(initial?.remarks ?: "") }
@@ -176,7 +179,7 @@ fun RoutingEditScreen(
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = stringResource(R.string.routing_settings_rule_title),
@@ -186,14 +189,14 @@ fun RoutingEditScreen(
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
contentDescription = stringResource(R.string.menu_item_del_config)
contentDescription = stringResource(R.string.acc_delete)
)
}
}
IconButton(onClick = { onSave(buildRuleset()) }) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.menu_item_save_config)
contentDescription = stringResource(R.string.acc_save)
)
}
}
@@ -220,21 +223,27 @@ fun RoutingEditScreen(
checked = locked,
onCheckedChange = { locked = it }
)
Text(
text = stringResource(R.string.routing_settings_tips),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FormTextField(
label = stringResource(R.string.routing_settings_domain),
placeholder = stringResource(R.string.routing_settings_tips),
placeholder = stringResource(R.string.routing_settings_comma_tip),
value = domain,
onValueChange = { domain = it }
)
FormTextField(
label = stringResource(R.string.routing_settings_ip),
placeholder = stringResource(R.string.routing_settings_tips),
placeholder = stringResource(R.string.routing_settings_comma_tip),
value = ip,
onValueChange = { ip = it }
)
FormTextField(
label = stringResource(R.string.routing_settings_process),
placeholder = stringResource(R.string.routing_settings_tips),
placeholder = stringResource(R.string.routing_settings_comma_tip),
value = processText,
onValueChange = { processText = it },
enabled = canUseProcess
@@ -251,7 +260,7 @@ fun RoutingEditScreen(
AppPickerActivity.createIntent(
context = context,
selectedPackages = current,
title = context.getString(R.string.routing_settings_process_select)
title = processSelectTitle
)
)
},
@@ -262,7 +271,7 @@ fun RoutingEditScreen(
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.routing_settings_process_select))
Text(processSelectTitle)
}
}
FormTextField(
@@ -284,12 +293,17 @@ fun RoutingEditScreen(
)
FormDropdownField(
label = stringResource(R.string.routing_settings_outbound_tag),
placeholder = stringResource(
R.string.routing_settings_outbound_tag_hint,
stringResource(R.string.server_lab_remarks)
),
value = outboundTag,
options = outboundSuggestions,
onValueChange = { outboundTag = it },
editable = true
)
Spacer(modifier = Modifier.height(36.dp))
NavigationBarsSpacer()
}
if (showDeleteConfirm) {
@@ -3,10 +3,12 @@ package com.v2ray.ang.ui.routing
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.annotation.StringRes
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -17,13 +19,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
@@ -46,18 +45,20 @@ import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.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.verticalScrollbar
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
@@ -69,6 +70,21 @@ import kotlinx.coroutines.withContext
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
private enum class RoutingMenuAction(@StringRes val labelRes: Int) {
ImportPredefined(R.string.routing_settings_import_predefined_rulesets),
ImportClipboard(R.string.routing_settings_import_rulesets_from_clipboard),
ImportQRCode(R.string.routing_settings_import_rulesets_from_qrcode),
ExportClipboard(R.string.routing_settings_export_rulesets_to_clipboard)
}
private enum class RoutingPreset(val type: RoutingType, @StringRes val labelRes: Int) {
ChinaWhitelist(RoutingType.WHITE, R.string.routing_preset_china_whitelist),
ChinaBlacklist(RoutingType.BLACK, R.string.routing_preset_china_blacklist),
Global(RoutingType.GLOBAL, R.string.routing_preset_global),
IranWhitelist(RoutingType.WHITE_IRAN, R.string.routing_preset_iran_whitelist),
RussiaWhitelist(RoutingType.WHITE_RUSSIA, R.string.routing_preset_russia_whitelist)
}
class RoutingSettingActivity : HelperBaseComponentActivity() {
private val viewModel: RoutingSettingsViewModel by viewModels()
private val domainStrategyState = MutableStateFlow("")
@@ -92,7 +108,7 @@ class RoutingSettingActivity : HelperBaseComponentActivity() {
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
domainStrategyState.value = value
},
onImportPredefined = { index -> importPredefined(index) },
onImportPredefined = { type -> importPredefined(type) },
onImportClipboard = { importFromClipboard() },
onImportQRcode = { importQRcode() },
onExportClipboard = { export2Clipboard() }
@@ -109,10 +125,10 @@ class RoutingSettingActivity : HelperBaseComponentActivity() {
return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: strategies.first()
}
private fun importPredefined(index: Int) {
private fun importPredefined(type: RoutingType) {
lifecycleScope.launch(Dispatchers.IO) {
try {
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, index)
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, type)
launch(Dispatchers.Main) {
viewModel.reload()
toastSuccess(R.string.toast_success)
@@ -172,7 +188,6 @@ class RoutingSettingActivity : HelperBaseComponentActivity() {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoutingSettingScreen(
viewModel: RoutingSettingsViewModel,
@@ -181,7 +196,7 @@ fun RoutingSettingScreen(
onAddRule: () -> Unit,
onEditRule: (Int) -> Unit,
onDomainStrategySelected: (String) -> Unit,
onImportPredefined: (Int) -> Unit,
onImportPredefined: (RoutingType) -> Unit,
onImportClipboard: () -> Unit,
onImportQRcode: () -> Unit,
onExportClipboard: () -> Unit
@@ -192,8 +207,6 @@ fun RoutingSettingScreen(
var showPresetDialog by remember { mutableStateOf(false) }
val domainStrategies = stringArrayResource(R.array.routing_domain_strategy).toList()
val presetRulesets = stringArrayResource(R.array.preset_rulesets).toList()
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
// Lazy list indices include the preceding non-rule content, so resolve the stable rule keys.
@@ -203,7 +216,7 @@ fun RoutingSettingScreen(
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = stringResource(R.string.routing_settings_title),
@@ -212,14 +225,14 @@ fun RoutingSettingScreen(
IconButton(onClick = onAddRule) {
Icon(
painterResource(R.drawable.ic_add_24dp),
contentDescription = stringResource(R.string.routing_settings_add_rule)
contentDescription = stringResource(R.string.acc_add_rule)
)
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
painterResource(R.drawable.ic_more_vert_24dp),
contentDescription = null
contentDescription = stringResource(R.string.acc_more)
)
}
DropdownMenu(
@@ -227,22 +240,15 @@ fun RoutingSettingScreen(
onDismissRequest = { showMenu = false },
containerColor = MaterialTheme.colorScheme.surface
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.routing_settings_import_predefined_rulesets)) },
onClick = { showMenu = false; showPresetDialog = true }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.routing_settings_import_rulesets_from_clipboard)) },
onClick = { showMenu = false; onImportClipboard() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.routing_settings_import_rulesets_from_qrcode)) },
onClick = { showMenu = false; onImportQRcode() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.routing_settings_export_rulesets_to_clipboard)) },
onClick = { showMenu = false; onExportClipboard() }
)
AppDropdownMenuItems(RoutingMenuAction.entries, { it.labelRes }) { action ->
showMenu = false
when (action) {
RoutingMenuAction.ImportPredefined -> showPresetDialog = true
RoutingMenuAction.ImportClipboard -> onImportClipboard()
RoutingMenuAction.ImportQRCode -> onImportQRcode()
RoutingMenuAction.ExportClipboard -> onExportClipboard()
}
}
}
}
}
@@ -254,7 +260,8 @@ fun RoutingSettingScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScrollbar(lazyListState)
.verticalScrollbar(lazyListState),
contentPadding = NavigationBarsBottomPadding()
) {
item(key = "domain_strategy") {
SettingsListItem(
@@ -301,10 +308,11 @@ fun RoutingSettingScreen(
if (showPresetDialog) {
SelectListDialog(
title = stringResource(R.string.routing_settings_import_predefined_rulesets),
options = presetRulesets,
onSelected = { index, _ ->
options = RoutingPreset.entries,
optionText = { stringResource(it.labelRes) },
onSelected = { preset ->
showPresetDialog = false
onImportPredefined(index)
onImportPredefined(preset.type)
},
onDismiss = { showPresetDialog = false }
)
@@ -335,7 +343,7 @@ private fun RoutingRulesetItem(
Spacer(modifier = Modifier.width(4.dp))
Icon(
painter = painterResource(R.drawable.ic_lock_24dp),
contentDescription = "Locked",
contentDescription = stringResource(R.string.acc_locked),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -369,7 +377,7 @@ private fun RoutingRulesetItem(
IconButton(onClick = onEdit) {
Icon(
painter = painterResource(R.drawable.ic_edit_24dp),
contentDescription = "Edit"
contentDescription = stringResource(R.string.acc_edit)
)
}
Spacer(modifier = Modifier.height(4.dp))
@@ -379,7 +387,7 @@ private fun RoutingRulesetItem(
modifier = Modifier.scale(0.7f),
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
checkedTrackColor = colorFabActive
checkedTrackColor = MaterialTheme.colorScheme.secondary
)
)
}
@@ -4,6 +4,7 @@ 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.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
@@ -14,7 +15,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -44,6 +44,7 @@ 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.NavigationBarsSpacer
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.JsonUtil
@@ -100,8 +101,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
@Composable
protected fun CommonBasicFields(
state: ServerUiState,
showPort: Boolean = true
state: ServerUiState
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FormTextField(
@@ -114,14 +114,12 @@ abstract class BaseServerActivity : BaseComponentActivity() {
state.address,
{ state.address = it }
)
if (showPort) {
FormTextField(
stringResource(R.string.server_lab_port),
state.port,
{ state.port = it },
keyboardType = KeyboardType.Number
)
}
FormTextField(
stringResource(R.string.server_lab_port),
state.port,
{ state.port = it },
keyboardType = KeyboardType.Number
)
}
}
@@ -154,9 +152,19 @@ abstract class BaseServerActivity : BaseComponentActivity() {
else -> R.string.server_lab_head_type
}
),
state.headerType,
when (state.network) {
NetworkType.GRPC.type -> state.mode
NetworkType.XHTTP.type -> state.xhttpMode
else -> state.headerType
},
headerOptions,
{ state.headerType = it }
{
when (state.network) {
NetworkType.GRPC.type -> state.mode = it
NetworkType.XHTTP.type -> state.xhttpMode = it
else -> state.headerType = it
}
}
)
}
@@ -173,25 +181,26 @@ abstract class BaseServerActivity : BaseComponentActivity() {
else -> R.string.server_lab_request_host6
}
),
state.host,
{ state.host = it }
if (state.network == NetworkType.GRPC.type) state.authority else state.host,
{ if (state.network == NetworkType.GRPC.type) state.authority = it else state.host = it }
)
FormTextField(
stringResource(
when (state.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
}
),
state.path,
{ state.path = it }
)
if (state.network != NetworkType.KCP.type) {
FormTextField(
stringResource(
when (state.network) {
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
}
),
if (state.network == NetworkType.GRPC.type) state.serviceName else state.path,
{ if (state.network == NetworkType.GRPC.type) state.serviceName = it else state.path = it }
)
}
if (state.network == NetworkType.XHTTP.type) {
FormTextField(
@@ -201,6 +210,11 @@ abstract class BaseServerActivity : BaseComponentActivity() {
)
}
if (state.network == NetworkType.KCP.type) {
FormTextField(
stringResource(R.string.server_lab_path_kcp),
state.seed,
{ state.seed = it }
)
FormTextField(
stringResource(R.string.server_lab_kcp_mtu),
state.kcpMtu,
@@ -435,7 +449,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
val scrollState = rememberScrollState()
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = title,
@@ -445,14 +459,14 @@ abstract class BaseServerActivity : BaseComponentActivity() {
IconButton(onClick = { showDeleteDialog = true }) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
stringResource(R.string.menu_item_del_config)
stringResource(R.string.acc_delete)
)
}
}
IconButton(onClick = onSaveClick) {
Icon(
painterResource(R.drawable.ic_fab_check),
stringResource(R.string.menu_item_save_config)
stringResource(R.string.acc_save)
)
}
}
@@ -468,9 +482,11 @@ abstract class BaseServerActivity : BaseComponentActivity() {
.verticalScroll(scrollState)
.verticalScrollbar(scrollState)
.padding(bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content
)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
content()
NavigationBarsSpacer()
}
}
if (showDeleteDialog) {
DeleteConfirmDialog(
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more