mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 22:49:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2020807c25 | ||
|
|
ca4185abad | ||
|
|
8d9152bb4e | ||
|
|
5bfcdf90ec | ||
|
|
f4f992eb9b | ||
|
|
1af8f4677c | ||
|
|
4d8ee1a916 | ||
|
|
95bcf32c0b | ||
|
|
c548e5f615 | ||
|
|
258de20972 | ||
|
|
3dc0173b9f | ||
|
|
13138dddca | ||
|
|
d5fc104e1d | ||
|
|
384553e74e | ||
|
|
11b5784321 | ||
|
|
e27941558e | ||
|
|
c73498b064 | ||
|
|
e2dc37ba26 | ||
|
|
26099c198e | ||
|
|
3c9336f671 | ||
|
|
767f88667c | ||
|
|
e3fa861571 | ||
|
|
fdacd58ed0 | ||
|
|
38da97e031 | ||
|
|
a1b45bbfb2 | ||
|
|
dac65a972c | ||
|
|
63f557242b |
No files matched your search
@@ -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.
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 745
|
||||
versionName = "2.3.5"
|
||||
versionCode = 747
|
||||
versionName = "2.3.7"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
|
||||
@@ -166,19 +166,19 @@
|
||||
android:name=".ui.shortcut.ScSwitchActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.shortcut.ScStartActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.shortcut.ScStopActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
|
||||
<activity
|
||||
@@ -209,6 +209,9 @@
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.TranslatorsActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".service.CoreVpnService"
|
||||
@@ -217,7 +220,7 @@
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
tools:ignore="VpnServicePolicy">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
@@ -235,7 +238,7 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
@@ -246,7 +249,7 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
@@ -256,7 +259,7 @@
|
||||
android:name=".service.CoreTestService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":tasks">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
@@ -266,7 +269,7 @@
|
||||
android:name=".service.SubscriptionUpdateService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":tasks">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
@@ -275,7 +278,7 @@
|
||||
<receiver
|
||||
android:name=".receiver.WidgetProvider"
|
||||
android:exported="true"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/app_widget_provider" />
|
||||
@@ -303,7 +306,7 @@
|
||||
android:icon="@drawable/ic_stat_name"
|
||||
android:label="@string/app_tile_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
@@ -324,7 +327,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"
|
||||
|
||||
@@ -168,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
|
||||
|
||||
@@ -113,7 +113,13 @@ object CoreConfigManager {
|
||||
}
|
||||
} else {
|
||||
json.remove("stats")
|
||||
json.remove("policy")
|
||||
// Keep user-defined policy levels, only strip the stats-related system block
|
||||
json.get("policy")?.takeIf { it.isJsonObject }?.asJsonObject?.let { policy ->
|
||||
policy.remove("system")
|
||||
if (policy.entrySet().isEmpty()) {
|
||||
json.remove("policy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!needTun()) {
|
||||
@@ -511,7 +517,7 @@ object CoreConfigManager {
|
||||
inbound1.listen = AppConfig.LOOPBACK
|
||||
}
|
||||
inbound1.port = socksPort
|
||||
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
|
||||
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
|
||||
if (socksUsername != null && socksPassword != null) {
|
||||
inbound1.settings?.auth = "password"
|
||||
inbound1.settings?.accounts = listOf(
|
||||
@@ -709,7 +715,7 @@ object CoreConfigManager {
|
||||
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
||||
v2rayConfig.stats = null
|
||||
v2rayConfig.policy = null
|
||||
v2rayConfig.policy?.system = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,7 +1058,7 @@ object CoreConfigManager {
|
||||
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
|
||||
*/
|
||||
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "1") {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ object CoreOutboundBuilder {
|
||||
if (muxEnabled) {
|
||||
outbound.mux?.enabled = true
|
||||
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY).orEmpty().toInt()
|
||||
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
|
||||
outbound.mux?.concurrency = -1
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.v2ray.ang.dto.ConnectionTestResult
|
||||
import com.v2ray.ang.dto.OutboundTrafficStat
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.BrowserDialerMode
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
@@ -31,7 +32,6 @@ import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
@@ -333,25 +333,14 @@ object CoreServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
val endpoint = if (time >= 0) SpeedtestManager.getRemoteIPInfo() else null
|
||||
val result = ConnectionTestResult(
|
||||
delayMillis = time,
|
||||
errorMessage = errorStr,
|
||||
country = endpoint?.country,
|
||||
ipAddress = endpoint?.ipAddress,
|
||||
)
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result)
|
||||
|
||||
// Only fetch IP info if the delay test was successful
|
||||
if (time >= 0) {
|
||||
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
|
||||
MessageHelper.sendMsg2UI(
|
||||
service,
|
||||
AppConfig.MSG_MEASURE_DELAY_RESULT,
|
||||
result.copy(
|
||||
country = ip.country,
|
||||
ipAddress = ip.ipAddress,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ object AngConfigManager {
|
||||
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
|
||||
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
|
||||
AppConfig.HY2 to Hysteria2Fmt::parse,
|
||||
AppConfig.V2RAYNFMTS to V2rayNFmt::parse
|
||||
)
|
||||
}
|
||||
|
||||
@@ -249,25 +248,34 @@ object AngConfigManager {
|
||||
|
||||
// Parse all configs first (no I/O during parsing)
|
||||
val configs = mutableListOf<ProfileItem>()
|
||||
val v2raynLines = mutableListOf<String>()
|
||||
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.reversed()
|
||||
.forEach {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
if (it.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)) {
|
||||
v2raynLines.add(it)
|
||||
} else {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (configs.isNotEmpty()) {
|
||||
val v2raynConfigs = V2rayNFmt.parse(v2raynLines, subid)
|
||||
val allConfigs = v2raynConfigs + configs
|
||||
|
||||
if (allConfigs.isNotEmpty()) {
|
||||
commitProfiles(
|
||||
configs = configs.map(::ParsedProfile),
|
||||
configs = allConfigs.map(::ParsedProfile),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
return configs.size
|
||||
return allConfigs.size
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
@@ -420,12 +428,6 @@ object AngConfigManager {
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
|
||||
if (str.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)
|
||||
&& config.policyGroupSubscriptionId == "self"
|
||||
) {
|
||||
config.policyGroupSubscriptionId = subid
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
|
||||
@@ -718,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 com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -176,7 +176,7 @@ object SettingsManager {
|
||||
* @return True if bypassing LAN, false otherwise.
|
||||
*/
|
||||
fun routingRulesetsBypassLan(): Boolean {
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
|
||||
if (vpnBypassLan == "1") {
|
||||
return true
|
||||
} else if (vpnBypassLan == "2") {
|
||||
@@ -467,7 +467,7 @@ object SettingsManager {
|
||||
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
|
||||
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
|
||||
|
||||
@@ -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
|
||||
@@ -3,12 +3,12 @@ package com.v2ray.ang.service
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.IDialerService
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -5,12 +5,12 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,7 +97,7 @@ class TProxyService(
|
||||
appendLine("misc:")
|
||||
appendLine(" tcp-read-write-timeout: ${tcpTimeout * 1000}")
|
||||
appendLine(" udp-read-write-timeout: ${udpTimeout * 1000}")
|
||||
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) ?: "warn"}")
|
||||
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# UI code agent guide
|
||||
|
||||
These rules apply to the UI paths listed in the repository-root `AGENTS.md`. Follow both
|
||||
guides; apply the precedence rule defined by the root guide.
|
||||
|
||||
## Compose architecture and state
|
||||
|
||||
- Implement every new screen and component with the existing Compose and Material 3
|
||||
stack. A new screen activity must extend `BaseComponentActivity` or
|
||||
`HelperBaseComponentActivity` and place its screen, ViewModel, and action contract in
|
||||
the package that owns its navigation entry. Do not add a fragment, ViewBinding layout,
|
||||
XML screen layout, or RecyclerView adapter. Do not migrate existing legacy UI outside
|
||||
task scope, and do not extend it or use it as the template for new UI.
|
||||
- Keep durable screen state and business work in a ViewModel/repository and expose it as
|
||||
immutable observable state. Collect flows with `collectAsStateWithLifecycle()`.
|
||||
Compose-local state is only for transient presentation state; use `rememberSaveable`
|
||||
when that state must survive activity recreation.
|
||||
- Durable state includes loaded data, selection by domain ID, validation results,
|
||||
progress, errors, and any value written to settings or a profile. Transient
|
||||
presentation state is limited to visual expansion, animation, scroll/focus position,
|
||||
and an uncommitted input value owned solely by the visible component. A composable
|
||||
must not perform repository, MMKV, import/export, routing, or native-core work.
|
||||
- Send user operations through the owning feature's action/event contract and
|
||||
ViewModel. Main-screen operations must use `MainAction` and `MainViewModel`.
|
||||
Composables must not mutate a repository, `MmkvManager`, or `SettingsManager`
|
||||
directly.
|
||||
- Use server GUIDs and group IDs for Compose keys, selection, saved state, and action
|
||||
parameters. Do not persist or dispatch a visible index, adapter position, or paging
|
||||
offset as identity. After filtering, sorting, subscription replacement, paging, or an
|
||||
asynchronous result, resolve the item again by its domain ID.
|
||||
- Run blocking file, network, package-manager, and bitmap-decoding operations on
|
||||
`Dispatchers.IO`. Run CPU-bound parsing, sorting, filtering, and transformation on
|
||||
`Dispatchers.Default`. Launch both from the owning ViewModel or lifecycle scope.
|
||||
Publish results through a private `MutableStateFlow` exposed as `StateFlow`; do not
|
||||
read or write Compose snapshot state on either background dispatcher.
|
||||
|
||||
## Interaction, accessibility, and layout
|
||||
|
||||
- Give every icon with a click, long-click, toggle, or custom accessibility action a
|
||||
localized accessible name that states its action. Set `contentDescription = null` on
|
||||
every icon with no user action. Do not expose a raw URL,
|
||||
package ID, GUID, or duplicated descendant label when a row-level semantic node
|
||||
supplies the name.
|
||||
- A row with one activation behavior must expose one focusable semantic node, one
|
||||
localized name, its current selected/on/off state, and one activation action. Merge or
|
||||
clear descendant semantics so a label, icon, checkbox, and switch do not become
|
||||
duplicate focus targets. Keep a descendant as a separate node only when it performs a
|
||||
different user action; give that node its own name and role. TalkBack, touch, Enter,
|
||||
Space, and D-pad center must invoke the same action for the same node.
|
||||
- Key each focusable list item by its domain ID. Reordering or recomposition must keep
|
||||
focus on the node with that ID if it still exists. If a selection change removes or
|
||||
replaces the focused node, move focus exactly once to the final active item after the
|
||||
state update; do not announce or focus an intermediate item.
|
||||
- Use `stringResource` for text created inside a composable and localized
|
||||
`Context.getString`/resources outside Compose. This rule covers visible labels,
|
||||
semantic labels, state descriptions, validation messages, errors, dialogs, snackbars,
|
||||
and toasts. Apply the root guide's locale and placeholder requirements to every key.
|
||||
- Every form containing a text-editing control must use a vertically scrollable
|
||||
container. Inside a `Scaffold`, apply its `innerPadding`, call
|
||||
`consumeWindowInsets(innerPadding)`, and
|
||||
apply `imePadding()` to that container. Outside a `Scaffold`, apply `imePadding()` and
|
||||
consume the navigation-bar inset exactly once. With the IME visible, the user must be
|
||||
able to focus, read, edit, and activate both the first and last form controls without
|
||||
dismissing the IME.
|
||||
- A shared component used by server screens must preserve edge-to-edge system-bar
|
||||
insets, light and dark themes, dynamic color enabled and disabled, and both single-
|
||||
and double-column layouts. A change to such a component requires rendering and
|
||||
interaction checks in each of those configurations; dynamic-color checks require a
|
||||
device or emulator that supports dynamic color.
|
||||
|
||||
## Validation mapping
|
||||
|
||||
Apply every row whose trigger matches the UI diff:
|
||||
|
||||
- ViewModel state, action mapping, validation, filtering, parsing, or selection logic:
|
||||
add or update a JVM test that asserts the initial state and every success, invalid-
|
||||
input, empty-input, and failure branch added or modified by the diff.
|
||||
- `rememberSaveable`, `SavedStateHandle`, activity recreation, or restoration logic:
|
||||
recreate the activity and verify the final state by domain ID, not list position.
|
||||
- Loading or data presentation: exercise every state represented by the owning state
|
||||
model among empty, loading, error, and populated; do not invent states absent from
|
||||
that model.
|
||||
- Click, toggle, selection, or navigation behavior: verify touch, keyboard focus and
|
||||
activation, and D-pad focus and activation.
|
||||
- Semantics, focus, accessible text, row merging, or icon meaning: inspect the semantics
|
||||
tree and run TalkBack on an emulator or device. Record the spoken label, state, focus
|
||||
order, and activation result.
|
||||
- Form fields, scrolling, or insets: open the IME, traverse from the first control to the
|
||||
last control, and activate the primary action with the IME still visible.
|
||||
- A setting, profile, routing rule, or other persisted value: perform the action, close
|
||||
and reopen the screen, and verify the value through the owning ViewModel/repository.
|
||||
|
||||
A screenshot verifies appearance only. It does not verify semantics, focus, activation,
|
||||
state ownership, persistence, or recreation. Record every mapped check that did not run
|
||||
under `Not run`; do not claim the corresponding behavior is verified.
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -46,12 +46,20 @@ class AboutActivity : BaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
AboutScreen(onBackClick = { finish() })
|
||||
AboutScreen(
|
||||
onBackClick = { finish() },
|
||||
onTranslatorsClick = {
|
||||
startActivity(Intent(this, TranslatorsActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AboutScreen(onBackClick: () -> Unit) {
|
||||
fun AboutScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onTranslatorsClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showOssDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -84,6 +92,11 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
title = stringResource(R.string.title_oss_license),
|
||||
onClick = { showOssDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_translate_24dp),
|
||||
title = stringResource(R.string.title_translators),
|
||||
onClick = onTranslatorsClick
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_feedback_24dp),
|
||||
title = stringResource(R.string.title_pref_feedback),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.TranslatorsCredit
|
||||
import com.v2ray.ang.dto.TranslatorsParser
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class TranslatorsActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
TranslatorsScreen(onBackClick = { finish() })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TranslatorsScreen(onBackClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val credits = remember {
|
||||
TranslatorsParser.parse(Utils.readTextFromAssets(context, "translators.json"))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_translators),
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(credits, key = { it.language }) { credit ->
|
||||
TranslationCreditCard(credit)
|
||||
}
|
||||
item {
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TranslationCreditCard(credit: TranslatorsCredit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = credit.language,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
credit.contributors.forEachIndexed { index, contributor ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
ContributorRow(
|
||||
displayName = contributor.displayName ?: contributor.name,
|
||||
linkUrl = contributor.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContributorRow(displayName: String, linkUrl: String?) {
|
||||
val context = LocalContext.current
|
||||
val isLink = linkUrl != null
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isLink) {
|
||||
Modifier.clickable(role = Role.Button) {
|
||||
Utils.openUri(context, linkUrl)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isLink) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_github_24dp),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (isLink) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier = Modifier.padding(start = if (isLink) 12.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
||||
@@ -40,8 +40,8 @@ import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.InputDialog
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.InputField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
@@ -185,17 +185,20 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun performRestore(cacheDir: File, zipFile: File): Boolean {
|
||||
val backupDir = cacheDir.absolutePath + "/${System.currentTimeMillis()}"
|
||||
private suspend fun performRestore(cacheDir: File, zipFile: File): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val backupDir = File(cacheDir, "restore_${System.nanoTime()}")
|
||||
try {
|
||||
if (!ZipUtil.unzipToFolder(zipFile, backupDir.absolutePath)) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
|
||||
return false
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir.absolutePath)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
count > 0
|
||||
} finally {
|
||||
backupDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui.checkupdate
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
||||
@@ -6,9 +6,9 @@ import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
|
||||
@@ -31,13 +31,12 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
enum class ToastType {
|
||||
NORMAL, SUCCESS, ERROR, INFO
|
||||
|
||||
@@ -12,9 +12,10 @@ import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -131,7 +132,7 @@ fun LogcatScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val logs by viewModel.filteredLogs.collectAsStateWithLifecycle()
|
||||
val logs by viewModel.logEntries.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
@@ -191,9 +192,10 @@ fun LogcatScreen(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = {
|
||||
viewModel.loadLogcat()
|
||||
}) {
|
||||
FloatingActionButton(
|
||||
onClick = { viewModel.loadLogcat() },
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_restore_24dp),
|
||||
contentDescription = stringResource(R.string.acc_refresh)
|
||||
@@ -213,8 +215,8 @@ fun LogcatScreen(
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(items = logs, key = { index, _ -> index }) { _, log ->
|
||||
LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) })
|
||||
items(items = logs, key = { it.key }) { log ->
|
||||
LogcatItem(log = log.text, onLongClick = { Utils.setClipboard(context, log.text) })
|
||||
ItemDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package com.v2ray.ang.ui.logcat
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.io.IOException
|
||||
|
||||
class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
@@ -17,6 +23,10 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
private val _filteredLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
|
||||
val logEntries: StateFlow<List<LogcatEntry>> = filteredLogs
|
||||
.map(::createLogcatEntries)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun loadLogcat() {
|
||||
launchLoading {
|
||||
@@ -73,4 +83,17 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
logsetsAll.filter { it.contains(currentFilter) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LogcatEntry(val key: String, val text: String)
|
||||
|
||||
internal fun createLogcatEntries(logs: List<String>): List<LogcatEntry> {
|
||||
val occurrences = HashMap<String, Int>()
|
||||
// Identical lines are valid. Count oldest-first so filtering other lines or
|
||||
// prepending newer entries does not change the keys of existing occurrences.
|
||||
return logs.asReversed().map { line ->
|
||||
val occurrence = occurrences.getOrDefault(line, 0)
|
||||
occurrences[line] = occurrence + 1
|
||||
LogcatEntry("$occurrence:$line", line)
|
||||
}.asReversed()
|
||||
}
|
||||
@@ -76,7 +76,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
ProfileEditorResult.EXTRA_RESTART_SERVICE, false
|
||||
)
|
||||
val selectedProfileSaved = action == ProfileEditorResult.ACTION_SAVED &&
|
||||
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
|
||||
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
|
||||
mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService || selectedProfileSaved) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.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
|
||||
|
||||
@@ -66,5 +66,5 @@ sealed interface MainAction {
|
||||
|
||||
data class ImportBatchConfig(val configText: String) : MainAction
|
||||
|
||||
data class LocateHandled(val target: LocateTarget) : MainAction
|
||||
data object LocateHandled : MainAction
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -11,10 +13,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
@@ -28,32 +29,28 @@ fun GroupTabBar(
|
||||
onTabClick: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTabIndex.coerceIn(0, groups.lastIndex),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedIndex,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
|
||||
edgePadding = 16.dp,
|
||||
minTabWidth = 56.dp,
|
||||
indicator = {
|
||||
TabRowDefaults.PrimaryIndicator(
|
||||
modifier = Modifier
|
||||
.tabIndicatorOffset(
|
||||
selectedTabIndex = selectedTabIndex.coerceIn(0, groups.lastIndex),
|
||||
matchContentSize = true
|
||||
)
|
||||
.clip(RoundedCornerShape(3.dp)),
|
||||
width = Dp.Unspecified,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.SecondaryIndicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
},
|
||||
divider = {}
|
||||
}
|
||||
) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
val serverFlow = remember(group.id, mainViewModel) {
|
||||
mainViewModel.serversForGroup(group.id)
|
||||
}
|
||||
GroupTabItem(
|
||||
group = group,
|
||||
selected = index == selectedTabIndex,
|
||||
serverFlowProvider = { mainViewModel.serversForGroup(group.id) },
|
||||
selected = index == selectedIndex,
|
||||
serverFlow = serverFlow,
|
||||
onClick = { onTabClick(index) }
|
||||
)
|
||||
}
|
||||
@@ -64,20 +61,23 @@ fun GroupTabBar(
|
||||
private fun GroupTabItem(
|
||||
group: GroupMapItem,
|
||||
selected: Boolean,
|
||||
serverFlowProvider: () -> StateFlow<List<ServersCache>>,
|
||||
serverFlow: StateFlow<List<ServersCache>>,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val serverFlow = remember(group.id) { serverFlowProvider() }
|
||||
val servers by serverFlow.collectAsStateWithLifecycle()
|
||||
val text = if (group.id.isEmpty()) {
|
||||
group.remarks
|
||||
} else {
|
||||
"${group.remarks} (${servers.size})"
|
||||
}
|
||||
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.heightIn(min = 48.dp),
|
||||
text = {
|
||||
val text = if (group.id.isEmpty()) {
|
||||
group.remarks
|
||||
} else {
|
||||
"${group.remarks} (${servers.size})"
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.ui.compose.LocalDarkTheme
|
||||
import com.v2ray.ang.ui.compose.QRCodeDialog
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -73,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 }
|
||||
@@ -93,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(16)
|
||||
}
|
||||
|
||||
if (latestDoubleColumnDisplay) {
|
||||
lazyGridStates[target.groupId]?.let { gridState ->
|
||||
gridState.scrollToItem(
|
||||
index = target.itemPosition,
|
||||
scrollOffset = -gridState.layoutInfo.viewportSize.height / 3
|
||||
)
|
||||
}
|
||||
} else {
|
||||
lazyListStates[target.groupId]?.let { listState ->
|
||||
listState.scrollToItem(
|
||||
index = target.itemPosition,
|
||||
scrollOffset = -listState.layoutInfo.viewportSize.height / 3
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
delay(32)
|
||||
locateInProgress = false
|
||||
mainViewModel.onAction(MainAction.LocateHandled(target))
|
||||
}
|
||||
}
|
||||
|
||||
MainDialogs(
|
||||
showDelAllConfirm = showDelAllConfirm,
|
||||
onDismissDelAll = { showDelAllConfirm = false },
|
||||
@@ -279,8 +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,6 +30,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -37,8 +38,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.LineBreak
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -46,12 +47,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.LocateTarget
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.ReorderableGridItem
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
@@ -69,8 +66,8 @@ fun GroupPagerPage(
|
||||
groupId: String,
|
||||
mainViewModel: MainViewModel,
|
||||
selectedGuid: String?,
|
||||
locateTarget: LocateTarget?,
|
||||
doubleColumnDisplay: Boolean,
|
||||
confirmRemove: Boolean,
|
||||
searchQuery: String,
|
||||
lazyListStates: MutableMap<String, LazyListState>,
|
||||
lazyGridStates: MutableMap<String, LazyGridState>,
|
||||
@@ -81,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
|
||||
) {
|
||||
@@ -135,6 +149,8 @@ private fun ServerListPage(
|
||||
}
|
||||
} else null
|
||||
|
||||
LocateTargetEffect(locateTarget, rows, gridState, onLocateHandled)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
state = gridState,
|
||||
@@ -143,24 +159,19 @@ private fun ServerListPage(
|
||||
.verticalScrollbar(gridState),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache ->
|
||||
itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row ->
|
||||
val content: @Composable () -> Unit = {
|
||||
ServerItemColumn(
|
||||
serverCache = serverCache,
|
||||
selectedGuid = selectedGuid,
|
||||
subscriptionId = subscriptionId,
|
||||
row = row,
|
||||
isSelected = row.guid == selectedGuid,
|
||||
doubleColumnDisplay = true,
|
||||
onSelectServer = onSelectServer,
|
||||
onEditServer = onEditServer,
|
||||
onShareServer = onShareServer,
|
||||
onMoreServer = onMoreServer,
|
||||
onRemoveServer = onRemoveServer
|
||||
actions = actions
|
||||
)
|
||||
}
|
||||
if (canReorder && reorderableGridState != null) {
|
||||
ReorderableItem(
|
||||
reorderableGridState,
|
||||
key = serverCache.guid
|
||||
key = row.guid
|
||||
) { isDragging ->
|
||||
ReorderableGridItem(
|
||||
scope = this,
|
||||
@@ -182,6 +193,8 @@ private fun ServerListPage(
|
||||
}
|
||||
} else null
|
||||
|
||||
LocateTargetEffect(locateTarget, rows, listState, onLocateHandled)
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
@@ -189,39 +202,29 @@ private fun ServerListPage(
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache ->
|
||||
itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row ->
|
||||
if (canReorder && reorderableState != null) {
|
||||
ReorderableItem(
|
||||
reorderableState,
|
||||
key = serverCache.guid
|
||||
key = row.guid
|
||||
) { isDragging ->
|
||||
ReorderableListItem(
|
||||
scope = this,
|
||||
isDragging = isDragging
|
||||
) {
|
||||
ServerItemRow(
|
||||
serverCache = serverCache,
|
||||
selectedGuid = selectedGuid,
|
||||
subscriptionId = subscriptionId,
|
||||
onSelectServer = onSelectServer,
|
||||
onEditServer = onEditServer,
|
||||
onShareServer = onShareServer,
|
||||
onMoreServer = onMoreServer,
|
||||
onRemoveServer = onRemoveServer
|
||||
row = row,
|
||||
isSelected = row.guid == selectedGuid,
|
||||
actions = actions
|
||||
)
|
||||
}
|
||||
ItemDivider()
|
||||
}
|
||||
} else {
|
||||
ServerItemRow(
|
||||
serverCache = serverCache,
|
||||
selectedGuid = selectedGuid,
|
||||
subscriptionId = subscriptionId,
|
||||
onSelectServer = onSelectServer,
|
||||
onEditServer = onEditServer,
|
||||
onShareServer = onShareServer,
|
||||
onMoreServer = onMoreServer,
|
||||
onRemoveServer = onRemoveServer
|
||||
row = row,
|
||||
isSelected = row.guid == selectedGuid,
|
||||
actions = actions
|
||||
)
|
||||
ItemDivider()
|
||||
}
|
||||
@@ -231,95 +234,80 @@ private fun ServerListPage(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerItemRow(
|
||||
serverCache: ServersCache,
|
||||
selectedGuid: String?,
|
||||
subscriptionId: String,
|
||||
onSelectServer: (String) -> Unit,
|
||||
onEditServer: (String, ProfileItem) -> Unit,
|
||||
onShareServer: (String, ProfileItem) -> Unit,
|
||||
onMoreServer: (String, ProfileItem) -> Unit,
|
||||
onRemoveServer: (String) -> Unit
|
||||
private fun LocateTargetEffect(
|
||||
target: LocateTarget?,
|
||||
rows: List<ServerRowUiModel>,
|
||||
state: LazyListState,
|
||||
onHandled: () -> Unit,
|
||||
) {
|
||||
val profile = serverCache.profile
|
||||
val subRemarks = if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
||||
?.toString() ?: ""
|
||||
} else ""
|
||||
if (target == null) return
|
||||
LaunchedEffect(target, rows) {
|
||||
val index = rows.indexOfFirst { it.guid == target.serverGuid }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
|
||||
onHandled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocateTargetEffect(
|
||||
target: LocateTarget?,
|
||||
rows: List<ServerRowUiModel>,
|
||||
state: LazyGridState,
|
||||
onHandled: () -> Unit,
|
||||
) {
|
||||
if (target == null) return
|
||||
LaunchedEffect(target, rows) {
|
||||
val index = rows.indexOfFirst { it.guid == target.serverGuid }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
|
||||
onHandled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerItemRow(
|
||||
row: ServerRowUiModel,
|
||||
isSelected: Boolean,
|
||||
actions: ServerRowActions
|
||||
) {
|
||||
ServerListItem(
|
||||
remarks = profile.remarks,
|
||||
statistics = profile.description.nullIfBlank()
|
||||
?: AngConfigManager.generateDescription(profile),
|
||||
typeDescription = getProtocolDescription(profile),
|
||||
testDelayMillis = serverCache.testDelayMillis,
|
||||
isSelected = serverCache.guid == selectedGuid,
|
||||
subscriptionRemarks = subRemarks,
|
||||
row = row,
|
||||
isSelected = isSelected,
|
||||
doubleColumnDisplay = false,
|
||||
onClick = { onSelectServer(serverCache.guid) },
|
||||
onShare = { onShareServer(serverCache.guid, profile) },
|
||||
onEdit = { onEditServer(serverCache.guid, profile) },
|
||||
onRemove = { onRemoveServer(serverCache.guid) },
|
||||
onMore = { onMoreServer(serverCache.guid, profile) }
|
||||
actions = actions
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerItemColumn(
|
||||
serverCache: ServersCache,
|
||||
selectedGuid: String?,
|
||||
subscriptionId: String,
|
||||
row: ServerRowUiModel,
|
||||
isSelected: Boolean,
|
||||
doubleColumnDisplay: Boolean,
|
||||
onSelectServer: (String) -> Unit,
|
||||
onEditServer: (String, ProfileItem) -> Unit,
|
||||
onShareServer: (String, ProfileItem) -> Unit,
|
||||
onMoreServer: (String, ProfileItem) -> Unit,
|
||||
onRemoveServer: (String) -> Unit
|
||||
actions: ServerRowActions
|
||||
) {
|
||||
val profile = serverCache.profile
|
||||
val subRemarks = if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()?.toString() ?: ""
|
||||
} else ""
|
||||
Column {
|
||||
ServerListItem(
|
||||
remarks = profile.remarks,
|
||||
statistics = profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile),
|
||||
typeDescription = getProtocolDescription(profile),
|
||||
testDelayMillis = serverCache.testDelayMillis,
|
||||
isSelected = serverCache.guid == selectedGuid,
|
||||
subscriptionRemarks = subRemarks,
|
||||
row = row,
|
||||
isSelected = isSelected,
|
||||
doubleColumnDisplay = doubleColumnDisplay,
|
||||
onClick = { onSelectServer(serverCache.guid) },
|
||||
onEdit = { onEditServer(serverCache.guid, profile) },
|
||||
onShare = { onShareServer(serverCache.guid, profile) },
|
||||
onRemove = { onRemoveServer(serverCache.guid) },
|
||||
onMore = { onMoreServer(serverCache.guid, profile) }
|
||||
actions = actions
|
||||
)
|
||||
ItemDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerListItem(
|
||||
remarks: String,
|
||||
statistics: String,
|
||||
typeDescription: String,
|
||||
testDelayMillis: Long,
|
||||
private fun ServerListItem(
|
||||
row: ServerRowUiModel,
|
||||
isSelected: Boolean,
|
||||
subscriptionRemarks: String,
|
||||
doubleColumnDisplay: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMore: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dragModifier: Modifier = Modifier
|
||||
actions: ServerRowActions
|
||||
) {
|
||||
val testResult = if (testDelayMillis == 0L) {
|
||||
val testResult = if (row.testDelayMillis == 0L) {
|
||||
""
|
||||
} else {
|
||||
stringResource(R.string.server_test_delay_value, testDelayMillis)
|
||||
stringResource(R.string.server_test_delay_value, row.testDelayMillis)
|
||||
}
|
||||
val selectedStateDescription = if (isSelected) {
|
||||
stringResource(R.string.acc_selected_server)
|
||||
@@ -327,7 +315,7 @@ fun ServerListItem(
|
||||
null
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.semantics {
|
||||
@@ -335,8 +323,7 @@ fun ServerListItem(
|
||||
stateDescription = selectedStateDescription
|
||||
}
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.then(dragModifier)
|
||||
.clickable { actions.select(row.guid) }
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -363,9 +350,9 @@ fun ServerListItem(
|
||||
.padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(row.remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (doubleColumnDisplay) {
|
||||
IconButton(onClick = onMore, Modifier.size(36.dp)) {
|
||||
IconButton(onClick = { actions.more(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
stringResource(R.string.acc_more),
|
||||
@@ -373,21 +360,21 @@ fun ServerListItem(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onShare, Modifier.size(36.dp)) {
|
||||
IconButton(onClick = { actions.share(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_share_24dp),
|
||||
stringResource(R.string.title_configuration_share),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onEdit, Modifier.size(36.dp)) {
|
||||
IconButton(onClick = { actions.edit(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_edit_24dp),
|
||||
stringResource(R.string.acc_edit),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRemove, Modifier.size(36.dp)) {
|
||||
IconButton(onClick = { actions.remove(row.guid) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_delete_24dp),
|
||||
stringResource(R.string.acc_delete),
|
||||
@@ -398,45 +385,34 @@ fun ServerListItem(
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (subscriptionRemarks.isNotBlank()) {
|
||||
if (row.subscriptionBadge.isNotBlank()) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), Alignment.Center
|
||||
) {
|
||||
Text(subscriptionRemarks.take(1).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
Text(row.subscriptionBadge.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Text(statistics, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
row.statistics,
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(row.typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (row.testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getProtocolDescription(profile: ProfileItem): String {
|
||||
if (profile.configType.isComplexType()) return profile.configType.name
|
||||
val parts = mutableListOf(profile.configType.name)
|
||||
profile.network?.let { net ->
|
||||
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) parts.add(net)
|
||||
}
|
||||
profile.security?.let { sec ->
|
||||
if (sec.isNotBlank()) {
|
||||
if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) {
|
||||
parts.add("$sec insecure")
|
||||
} else {
|
||||
parts.add(sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.joinToString(" / ")
|
||||
}
|
||||
|
||||
internal suspend fun PagerState.navigateToPageOptimized(
|
||||
targetPage: Int,
|
||||
animateAdjacentPage: Boolean = true
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
|
||||
internal data class ServerRowUiModel(
|
||||
val guid: String,
|
||||
val profile: ProfileItem,
|
||||
val remarks: String,
|
||||
val statistics: String,
|
||||
val typeDescription: String,
|
||||
val testDelayMillis: Long,
|
||||
val subscriptionBadge: String,
|
||||
)
|
||||
|
||||
internal data class ServerGroupUiState(
|
||||
val servers: List<ServersCache> = emptyList(),
|
||||
val rows: List<ServerRowUiModel> = emptyList(),
|
||||
)
|
||||
|
||||
internal fun buildServerRowUiModel(
|
||||
server: ServersCache,
|
||||
subscriptionRemarks: String,
|
||||
): ServerRowUiModel {
|
||||
val profile = server.profile
|
||||
return ServerRowUiModel(
|
||||
guid = server.guid,
|
||||
profile = profile,
|
||||
remarks = profile.remarks,
|
||||
statistics = profile.description.nullIfBlank()
|
||||
?: AngConfigManager.generateDescription(profile),
|
||||
typeDescription = serverProtocolDescription(profile),
|
||||
testDelayMillis = server.testDelayMillis,
|
||||
subscriptionBadge = subscriptionRemarks.firstOrNull()?.toString().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun serverProtocolDescription(profile: ProfileItem): String {
|
||||
if (profile.configType.isComplexType()) return profile.configType.name
|
||||
val parts = mutableListOf(profile.configType.name)
|
||||
profile.network?.let { network ->
|
||||
if (network.isNotBlank() && !network.equals("tcp", ignoreCase = true)) {
|
||||
parts.add(network)
|
||||
}
|
||||
}
|
||||
profile.security?.let { security ->
|
||||
if (security.isNotBlank()) {
|
||||
parts.add(
|
||||
if (profile.insecure == true && security.equals("tls", ignoreCase = true)) {
|
||||
"$security insecure"
|
||||
} else {
|
||||
security
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return parts.joinToString(" / ")
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -24,11 +25,13 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -65,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>()
|
||||
|
||||
@@ -163,14 +167,25 @@ class MainViewModel(
|
||||
|
||||
// ---------- Public state accessors ----------
|
||||
fun serversForGroup(groupId: String): StateFlow<List<ServersCache>> =
|
||||
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) }
|
||||
.asStateFlow()
|
||||
groupServerFlows.computeIfAbsent(groupId) {
|
||||
val groupState = mutableServerGroupState(groupId)
|
||||
groupState
|
||||
.map { it.servers }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
|
||||
initialValue = groupState.value.servers,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mutableServersForGroup(groupId: String): MutableStateFlow<List<ServersCache>> =
|
||||
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) }
|
||||
internal fun serverGroupState(groupId: String): StateFlow<ServerGroupUiState> =
|
||||
mutableServerGroupState(groupId).asStateFlow()
|
||||
|
||||
private fun mutableServerGroupState(groupId: String): MutableStateFlow<ServerGroupUiState> =
|
||||
groupUiFlows.computeIfAbsent(groupId) { MutableStateFlow(ServerGroupUiState()) }
|
||||
|
||||
private fun currentServers(): List<ServersCache> =
|
||||
mutableServersForGroup(uiState.value.selectedGroupId).value
|
||||
mutableServerGroupState(uiState.value.selectedGroupId).value.servers
|
||||
|
||||
// ---------- Action handler ----------
|
||||
fun onAction(action: MainAction) {
|
||||
@@ -191,7 +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) }
|
||||
@@ -289,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()
|
||||
@@ -334,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() }
|
||||
@@ -582,7 +622,7 @@ class MainViewModel(
|
||||
|
||||
fun subscriptionIdChanged(id: String) {
|
||||
if (_uiState.value.groups.none { it.id == id }) return
|
||||
mutableServersForGroup(id)
|
||||
mutableServerGroupState(id)
|
||||
if (uiState.value.selectedGroupId != id) {
|
||||
dataSource.setSelectedSubscriptionId(id)
|
||||
_uiState.update { it.copy(selectedGroupId = id) }
|
||||
@@ -660,10 +700,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) {
|
||||
@@ -694,11 +737,17 @@ class MainViewModel(
|
||||
return
|
||||
}
|
||||
val serverGuids = servers.map { it.guid }
|
||||
mutableServersForGroup(groupId).update { current ->
|
||||
current.map { server ->
|
||||
if (server.testDelayMillis == 0L) server
|
||||
else server.copy(testDelayMillis = 0L)
|
||||
}
|
||||
mutableServerGroupState(groupId).update { current ->
|
||||
current.copy(
|
||||
servers = current.servers.map { server ->
|
||||
if (server.testDelayMillis == 0L) server
|
||||
else server.copy(testDelayMillis = 0L)
|
||||
},
|
||||
rows = current.rows.map { row ->
|
||||
if (row.testDelayMillis == 0L) row
|
||||
else row.copy(testDelayMillis = 0L)
|
||||
}
|
||||
)
|
||||
}
|
||||
testingGroupId = groupId
|
||||
_uiState.update {
|
||||
@@ -744,24 +793,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 ----------
|
||||
|
||||
@@ -49,7 +49,6 @@ 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.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -54,12 +54,11 @@ import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.colorConfigType
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui.server
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
||||
@@ -91,6 +91,11 @@ class ServerHysteria2Activity : BaseServerActivity() {
|
||||
state.sni,
|
||||
{ state.sni = it }
|
||||
)
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_ech_config_list),
|
||||
state.echConfigList,
|
||||
{ state.echConfigList = it }
|
||||
)
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_pinned_ca256),
|
||||
state.pinnedCA256,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.v2ray.ang.ui.settings
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -24,10 +27,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
|
||||
@@ -44,7 +51,9 @@ import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.ThemeManager
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsActivity : BaseComponentActivity() {
|
||||
|
||||
@@ -52,6 +61,26 @@ class SettingsActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
viewModel.refreshSystemVpnSettingsAvailability()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSystemVpnSettings() {
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||
} catch (error: ActivityNotFoundException) {
|
||||
reportSystemVpnSettingsFailure(error)
|
||||
} catch (error: SecurityException) {
|
||||
reportSystemVpnSettingsFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportSystemVpnSettingsFailure(error: RuntimeException) {
|
||||
LogUtil.e(AppConfig.TAG, "Cannot open system VPN settings", error)
|
||||
toastError(R.string.toast_system_vpn_settings_unavailable)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -59,7 +88,8 @@ class SettingsActivity : BaseComponentActivity() {
|
||||
SettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBackClick = { finish() },
|
||||
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) }
|
||||
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) },
|
||||
onSystemVpnSettingsClicked = ::openSystemVpnSettings
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -69,10 +99,12 @@ class SettingsActivity : BaseComponentActivity() {
|
||||
fun SettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBackClick: () -> Unit,
|
||||
onModeHelpClicked: () -> Unit
|
||||
onModeHelpClicked: () -> Unit,
|
||||
onSystemVpnSettingsClicked: () -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
val systemVpnSettingsAvailable by viewModel.systemVpnSettingsAvailable.collectAsStateWithLifecycle()
|
||||
var uiSettingsExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
var vpnSettingsExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
var coreSettingsExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
@@ -86,13 +118,13 @@ fun SettingsScreen(
|
||||
var fakeDns by rememberMmkvBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
|
||||
var appendHttpProxy by rememberMmkvBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
|
||||
var vpnDns by rememberMmkvString(AppConfig.PREF_VPN_DNS, "")
|
||||
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, "0")
|
||||
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
|
||||
var vpnInterfaceAddress by rememberMmkvString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")
|
||||
var vpnMtu by rememberMmkvString(AppConfig.PREF_VPN_MTU, "")
|
||||
|
||||
var mux by rememberMmkvBool(AppConfig.PREF_MUX_ENABLED, false)
|
||||
var muxConcurrency by rememberMmkvString(AppConfig.PREF_MUX_CONCURRENCY, "8")
|
||||
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
|
||||
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
|
||||
var muxXudpQuic by rememberMmkvString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||
|
||||
var fragment by rememberMmkvBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
|
||||
@@ -110,7 +142,7 @@ fun SettingsScreen(
|
||||
var enableRootMode by rememberMmkvBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
|
||||
var lanSharing by rememberMmkvBool(AppConfig.PREF_ROOT_LAN_SHARING, false)
|
||||
|
||||
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, "warning")
|
||||
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)
|
||||
var hevTunRwTimeout by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, "")
|
||||
var useHevTun by rememberMmkvBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
|
||||
|
||||
@@ -119,7 +151,7 @@ fun SettingsScreen(
|
||||
var dynamicSocksPort by rememberMmkvBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
|
||||
var socksUsername by rememberMmkvString(AppConfig.PREF_SOCKS_USERNAME, "")
|
||||
var socksPassword by rememberMmkvString(AppConfig.PREF_SOCKS_PASSWORD, "")
|
||||
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, false)
|
||||
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
|
||||
var proxySharing by rememberMmkvBool(AppConfig.PREF_PROXY_SHARING, false)
|
||||
|
||||
var speedEnabled by rememberMmkvBool(AppConfig.PREF_SPEED_ENABLED, false)
|
||||
@@ -142,7 +174,7 @@ fun SettingsScreen(
|
||||
var domesticDns by rememberMmkvString(AppConfig.PREF_DOMESTIC_DNS, "")
|
||||
var dnsHosts by rememberMmkvString(AppConfig.PREF_DNS_HOSTS, "")
|
||||
var coreLogLevel by rememberMmkvString(AppConfig.PREF_LOGLEVEL, "warning")
|
||||
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "0")
|
||||
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD)
|
||||
|
||||
var isBooted by rememberMmkvBool(AppConfig.PREF_IS_BOOTED, false)
|
||||
var delayTestUrl by rememberMmkvString(AppConfig.PREF_DELAY_TEST_URL, "")
|
||||
@@ -153,7 +185,7 @@ fun SettingsScreen(
|
||||
val hevTunEnabled = isVpn && useHevTun
|
||||
val localProxyForced = hevTunEnabled
|
||||
val effectiveLocalProxy = enableLocalProxy || localProxyForced
|
||||
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: 8
|
||||
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY.toInt()
|
||||
|
||||
val dynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
LaunchedEffect(dynamicColorSupported) {
|
||||
@@ -612,6 +644,13 @@ fun SettingsScreen(
|
||||
checked = isBooted,
|
||||
onCheckedChange = { isBooted = it }
|
||||
)
|
||||
if (systemVpnSettingsAvailable) {
|
||||
SettingsMenuItem(
|
||||
title = stringResource(R.string.title_system_vpn_settings),
|
||||
subtitle = stringResource(R.string.summary_system_vpn_settings),
|
||||
onClick = onSystemVpnSettingsClicked
|
||||
)
|
||||
}
|
||||
SettingsEditItem(
|
||||
title = stringResource(R.string.title_pref_delay_test_url),
|
||||
value = delayTestUrl,
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
package com.v2ray.ang.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SettingsViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
private val _systemVpnSettingsAvailable = MutableStateFlow(false)
|
||||
val systemVpnSettingsAvailable = _systemVpnSettingsAvailable.asStateFlow()
|
||||
|
||||
suspend fun refreshSystemVpnSettingsAvailability() {
|
||||
_systemVpnSettingsAvailable.value = withContext(Dispatchers.IO) {
|
||||
// Android exposes the VPN page, not a direct link to the Always-on VPN switch.
|
||||
Intent(Settings.ACTION_VPN_SETTINGS).resolveActivity(getApplication<Application>().packageManager) != null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for root access and requests it if necessary.
|
||||
* Updates [isLoading] during the process.
|
||||
@@ -56,4 +70,4 @@ class SettingsViewModel(application: Application) : BaseViewModel(application) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,15 +34,19 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
data class TaskerItem(
|
||||
val label: String,
|
||||
val guid: String,
|
||||
)
|
||||
|
||||
class TaskerActivity : BaseComponentActivity() {
|
||||
|
||||
private var lstData: ArrayList<String> = ArrayList()
|
||||
private var lstGuid: ArrayList<String> = ArrayList()
|
||||
private val items = mutableListOf<TaskerItem>()
|
||||
|
||||
private val switchState = mutableStateOf(false)
|
||||
private val selectedPosition = mutableStateOf(-1)
|
||||
@@ -50,13 +54,11 @@ class TaskerActivity : BaseComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
lstData.add("Default")
|
||||
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
||||
items.add(TaskerItem(label = "Default", guid = AppConfig.TASKER_DEFAULT_GUID))
|
||||
|
||||
MmkvManager.decodeAllServerList().forEach { key ->
|
||||
MmkvManager.decodeServerConfig(key)?.let { config ->
|
||||
lstData.add(config.remarks)
|
||||
lstGuid.add(key)
|
||||
items.add(TaskerItem(label = config.remarks, guid = key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ class TaskerActivity : BaseComponentActivity() {
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
TaskerScreen(
|
||||
items = lstData,
|
||||
items = items,
|
||||
switchState = switchState,
|
||||
selectedPosition = selectedPosition,
|
||||
onBackClick = { finish() },
|
||||
@@ -84,10 +86,7 @@ class TaskerActivity : BaseComponentActivity() {
|
||||
return
|
||||
} else {
|
||||
switchState.value = switch
|
||||
val pos = lstGuid.indexOf(guid.toString())
|
||||
if (pos >= 0) {
|
||||
selectedPosition.value = pos
|
||||
}
|
||||
selectedPosition.value = items.indexOfFirst { it.guid == guid.toString() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
|
||||
@@ -102,13 +101,12 @@ class TaskerActivity : BaseComponentActivity() {
|
||||
|
||||
val extraBundle = Bundle()
|
||||
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, switchState.value)
|
||||
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
|
||||
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, items[position].guid)
|
||||
val intent = Intent()
|
||||
|
||||
val remarks = lstData[position]
|
||||
val blurb = getString(
|
||||
if (switchState.value) R.string.tasker_blurb_start else R.string.tasker_blurb_stop,
|
||||
remarks
|
||||
items[position].label
|
||||
)
|
||||
|
||||
intent.putExtra(AppConfig.TASKER_EXTRA_BUNDLE, extraBundle)
|
||||
@@ -120,7 +118,7 @@ class TaskerActivity : BaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
fun TaskerScreen(
|
||||
items: List<String>,
|
||||
items: List<TaskerItem>,
|
||||
switchState: MutableState<Boolean>,
|
||||
selectedPosition: MutableState<Int>,
|
||||
onBackClick: () -> Unit,
|
||||
@@ -158,7 +156,7 @@ fun TaskerScreen(
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(items) { index, remarks ->
|
||||
itemsIndexed(items, key = { _, item -> item.guid }) { index, item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -171,7 +169,7 @@ fun TaskerScreen(
|
||||
onClick = { selectedPosition.value = index }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = remarks, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(text = item.label, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui.subscription
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
||||
@@ -47,12 +47,11 @@ import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.QRCodeDialog
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -56,8 +56,8 @@ import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui.userasset
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
||||
@@ -16,7 +16,6 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
@@ -213,8 +212,10 @@ object Utils {
|
||||
*/
|
||||
private fun isIpv6Address(value: String): Boolean {
|
||||
var addr = value
|
||||
if (addr.startsWith("[") && addr.endsWith("]")) {
|
||||
addr = addr.drop(1).dropLast(1)
|
||||
if (addr.startsWith("[")) {
|
||||
val closingBracket = addr.lastIndexOf(']')
|
||||
if (closingBracket <= 1) return false
|
||||
addr = addr.substring(1, closingBracket)
|
||||
}
|
||||
return IPV6_REGEX.matches(addr)
|
||||
}
|
||||
@@ -460,48 +461,23 @@ object Utils {
|
||||
fun isXray(): Boolean = BuildConfig.APPLICATION_ID.startsWith("com.v2ray.ang")
|
||||
|
||||
/**
|
||||
* Converts an InetAddress to its long representation
|
||||
* Check if an IPv4 address is within an IPv4 CIDR range
|
||||
*
|
||||
* @param ip The InetAddress to convert
|
||||
* @return The long representation of the IP address
|
||||
*/
|
||||
private fun inetAddressToLong(ip: InetAddress): Long {
|
||||
val bytes = ip.address
|
||||
var result: Long = 0
|
||||
for (i in bytes.indices) {
|
||||
result = result shl 8 or (bytes[i].toInt() and 0xff).toLong()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is within a CIDR range
|
||||
*
|
||||
* @param ip The IP address to check
|
||||
* @param cidr The CIDR notation range (e.g., "192.168.1.0/24")
|
||||
* @param ip The IPv4 address to check
|
||||
* @param cidr The IPv4 CIDR range (e.g., "192.168.1.0/24")
|
||||
* @return True if the IP is within the CIDR range, false otherwise
|
||||
*/
|
||||
fun isIpInCidr(ip: String, cidr: String): Boolean {
|
||||
try {
|
||||
if (!isIpAddress(ip)) return false
|
||||
val parts = cidr.split('/')
|
||||
if (parts.size != 2 || !isIpv4Address(ip) || !isIpv4Address(parts[0])) return false
|
||||
|
||||
// Parse CIDR (e.g., "192.168.1.0/24")
|
||||
val (cidrIp, prefixLen) = cidr.split("/")
|
||||
val prefixLength = prefixLen.toInt()
|
||||
val prefixLength = parts[1].toIntOrNull()?.takeIf { it in 0..32 } ?: return false
|
||||
val mask = if (prefixLength == 0) 0L else (-1L shl (32 - prefixLength))
|
||||
return (ipv4ToLong(ip) and mask) == (ipv4ToLong(parts[0]) and mask)
|
||||
}
|
||||
|
||||
// Convert IP and CIDR's IP portion to Long
|
||||
val ipLong = inetAddressToLong(InetAddress.getByName(ip))
|
||||
val cidrIpLong = inetAddressToLong(InetAddress.getByName(cidrIp))
|
||||
|
||||
// Calculate subnet mask (e.g., /24 → 0xFFFFFF00)
|
||||
val mask = if (prefixLength == 0) 0L else (-1L shl (32 - prefixLength))
|
||||
|
||||
// Check if they're in the same subnet
|
||||
return (ipLong and mask) == (cidrIpLong and mask)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to check if IP is in CIDR", e)
|
||||
return false
|
||||
}
|
||||
private fun ipv4ToLong(ip: String): Long {
|
||||
return ip.split('.').fold(0L) { result, octet -> (result shl 8) or octet.toLong() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,11 +8,37 @@ import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipException
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
object ZipUtil {
|
||||
private const val BUFFER_SIZE = 4096
|
||||
private const val MEBIBYTE = 1024L * 1024L
|
||||
|
||||
private val DEFAULT_EXTRACTION_LIMITS = ExtractionLimits(
|
||||
maxArchiveBytes = 128L * MEBIBYTE,
|
||||
maxEntries = 32,
|
||||
maxEntryBytes = 128L * MEBIBYTE,
|
||||
maxTotalBytes = 256L * MEBIBYTE,
|
||||
maxCompressionRatio = 1000.0,
|
||||
)
|
||||
|
||||
internal class ExtractionLimits(
|
||||
val maxArchiveBytes: Long,
|
||||
val maxEntries: Int,
|
||||
val maxEntryBytes: Long,
|
||||
val maxTotalBytes: Long,
|
||||
val maxCompressionRatio: Double,
|
||||
) {
|
||||
init {
|
||||
require(maxArchiveBytes > 0)
|
||||
require(maxEntries > 0)
|
||||
require(maxEntryBytes > 0)
|
||||
require(maxTotalBytes > 0)
|
||||
require(maxCompressionRatio >= 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zip the contents of a folder.
|
||||
@@ -78,47 +104,181 @@ object ZipUtil {
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun unzipToFolder(zipFile: File, destDirectory: String): Boolean {
|
||||
File(destDirectory).run {
|
||||
if (!exists()) {
|
||||
mkdirs()
|
||||
}
|
||||
}
|
||||
try {
|
||||
ZipFile(zipFile).use { zip ->
|
||||
zip.entries().asSequence().forEach { entry ->
|
||||
zip.getInputStream(entry).use { input ->
|
||||
val filePath = destDirectory + File.separator + entry.name
|
||||
if (!entry.isDirectory) {
|
||||
extractFile(input, filePath)
|
||||
} else {
|
||||
val dir = File(filePath)
|
||||
dir.mkdir()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return try {
|
||||
extractArchive(zipFile, File(destDirectory), DEFAULT_EXTRACTION_LIMITS)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to unzip file", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
internal fun extractArchive(
|
||||
zipFile: File,
|
||||
destination: File,
|
||||
limits: ExtractionLimits,
|
||||
) {
|
||||
if (!zipFile.isFile || zipFile.length() > limits.maxArchiveBytes) {
|
||||
throw ZipException("ZIP archive is missing or exceeds the compressed-size limit")
|
||||
}
|
||||
|
||||
val root = destination.canonicalFile
|
||||
ZipFile(zipFile).use { zip ->
|
||||
val entries = validateEntries(zip, root, limits)
|
||||
val rootCreated = prepareDestination(root)
|
||||
try {
|
||||
extractEntries(zip, entries, limits)
|
||||
} catch (error: Throwable) {
|
||||
root.listFiles()?.forEach { it.deleteRecursively() }
|
||||
if (rootCreated) root.delete()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateEntries(
|
||||
zip: ZipFile,
|
||||
root: File,
|
||||
limits: ExtractionLimits,
|
||||
): Map<File, ZipEntry> {
|
||||
val entriesByTarget = LinkedHashMap<File, ZipEntry>()
|
||||
var totalBytes = 0L
|
||||
val entries = zip.entries()
|
||||
|
||||
while (entries.hasMoreElements()) {
|
||||
if (entriesByTarget.size >= limits.maxEntries) {
|
||||
throw ZipException("ZIP archive exceeds the entry-count limit")
|
||||
}
|
||||
|
||||
val entry = entries.nextElement()
|
||||
if (entry.name.isEmpty()) {
|
||||
throw ZipException("ZIP archive contains an empty entry name")
|
||||
}
|
||||
if (entry.method != ZipEntry.STORED && entry.method != ZipEntry.DEFLATED) {
|
||||
throw ZipException("ZIP archive contains an unsupported compression method")
|
||||
}
|
||||
|
||||
val target = resolveEntryTarget(root, entry)
|
||||
if (entriesByTarget.put(target, entry) != null) {
|
||||
throw ZipException("ZIP archive contains duplicate entry destinations")
|
||||
}
|
||||
|
||||
val entryBytes = entry.size
|
||||
val compressedBytes = entry.compressedSize
|
||||
if (entryBytes < 0L || compressedBytes < 0L) {
|
||||
throw ZipException("ZIP archive contains an entry with an unknown size")
|
||||
}
|
||||
if (entry.isDirectory && entryBytes != 0L) {
|
||||
throw ZipException("ZIP archive contains a non-empty directory entry")
|
||||
}
|
||||
if (entryBytes > limits.maxEntryBytes) {
|
||||
throw ZipException("ZIP archive entry exceeds the expanded-size limit")
|
||||
}
|
||||
if (totalBytes > limits.maxTotalBytes - entryBytes) {
|
||||
throw ZipException("ZIP archive exceeds the total expanded-size limit")
|
||||
}
|
||||
if (
|
||||
entryBytes > 0L && (
|
||||
compressedBytes == 0L ||
|
||||
entryBytes.toDouble() / compressedBytes > limits.maxCompressionRatio
|
||||
)
|
||||
) {
|
||||
throw ZipException("ZIP archive entry exceeds the compression-ratio limit")
|
||||
}
|
||||
|
||||
totalBytes += entryBytes
|
||||
}
|
||||
|
||||
entriesByTarget.forEach { (target, _) ->
|
||||
var parent = target.parentFile
|
||||
while (parent != null && parent != root) {
|
||||
if (entriesByTarget[parent]?.isDirectory == false) {
|
||||
throw ZipException("ZIP archive places an entry below a file")
|
||||
}
|
||||
parent = parent.parentFile
|
||||
}
|
||||
}
|
||||
return entriesByTarget
|
||||
}
|
||||
|
||||
private fun resolveEntryTarget(root: File, entry: ZipEntry): File {
|
||||
val target = File(root, entry.name).canonicalFile
|
||||
val rootPrefix = root.path + File.separator
|
||||
if (target == root || !target.path.startsWith(rootPrefix)) {
|
||||
throw ZipException("ZIP entry resolves outside the destination directory")
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun prepareDestination(root: File): Boolean {
|
||||
if (root.exists()) {
|
||||
if (!root.isDirectory || root.listFiles()?.isNotEmpty() != false) {
|
||||
throw IOException("ZIP destination must be an empty directory")
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!root.mkdirs()) {
|
||||
throw IOException("Unable to create ZIP destination directory")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a file from an input stream.
|
||||
*
|
||||
* @param inputStream The input stream to read from.
|
||||
* @param destFilePath The destination file path.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
private fun extractFile(inputStream: InputStream, destFilePath: String) {
|
||||
val bos = BufferedOutputStream(FileOutputStream(destFilePath))
|
||||
val bytesIn = ByteArray(BUFFER_SIZE)
|
||||
var read: Int
|
||||
while (inputStream.read(bytesIn).also { read = it } != -1) {
|
||||
bos.write(bytesIn, 0, read)
|
||||
private fun extractEntries(
|
||||
zip: ZipFile,
|
||||
entries: Map<File, ZipEntry>,
|
||||
limits: ExtractionLimits,
|
||||
) {
|
||||
var totalBytes = 0L
|
||||
entries.forEach { (target, entry) ->
|
||||
if (entry.isDirectory) {
|
||||
if (!target.isDirectory && !target.mkdirs()) {
|
||||
throw IOException("Unable to create ZIP entry directory")
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val parent = target.parentFile
|
||||
?: throw ZipException("ZIP entry has no destination parent")
|
||||
if (!parent.isDirectory && !parent.mkdirs()) {
|
||||
throw IOException("Unable to create ZIP entry parent directory")
|
||||
}
|
||||
if (target.exists()) {
|
||||
throw ZipException("ZIP entry would overwrite an existing file")
|
||||
}
|
||||
|
||||
zip.getInputStream(entry).use { input ->
|
||||
BufferedOutputStream(FileOutputStream(target)).use { output ->
|
||||
totalBytes += copyEntry(input, output, entry.size, totalBytes, limits)
|
||||
}
|
||||
}
|
||||
}
|
||||
bos.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyEntry(
|
||||
input: InputStream,
|
||||
output: BufferedOutputStream,
|
||||
expectedBytes: Long,
|
||||
currentTotalBytes: Long,
|
||||
limits: ExtractionLimits,
|
||||
): Long {
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var entryBytes = 0L
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
val readBytes = read.toLong()
|
||||
val remainingEntryBytes = limits.maxEntryBytes - entryBytes
|
||||
val remainingTotalBytes = limits.maxTotalBytes - currentTotalBytes - entryBytes
|
||||
if (readBytes > remainingEntryBytes || readBytes > remainingTotalBytes
|
||||
) {
|
||||
throw ZipException("ZIP entry exceeds the expanded-size limit while extracting")
|
||||
}
|
||||
output.write(buffer, 0, read)
|
||||
entryBytes += readBytes
|
||||
}
|
||||
if (entryBytes != expectedBytes) {
|
||||
throw ZipException("ZIP entry expanded size differs from its declared size")
|
||||
}
|
||||
return entryBytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,2C6.48,2 2,6.58 2,12.25c0,4.75 3.07,8.78 7.33,10.2c0.54,0.1 0.73,-0.23 0.73,-0.52v-1.82c-2.98,0.65 -3.61,-1.44 -3.61,-1.44c-0.49,-1.27 -1.19,-1.61 -1.19,-1.61c-0.98,-0.67 0.07,-0.66 0.07,-0.66c1.08,0.08 1.65,1.13 1.65,1.13c0.96,1.68 2.52,1.2 3.13,0.92c0.1,-0.71 0.37,-1.2 0.68,-1.47c-2.39,-0.27 -4.9,-1.22 -4.9,-5.42c0,-1.2 0.42,-2.18 1.1,-2.94c-0.11,-0.28 -0.48,-1.39 0.1,-2.91c0,0 0.9,-0.29 2.95,1.12c0.86,-0.24 1.78,-0.37 2.7,-0.37c0.92,0 1.84,0.13 2.7,0.37c2.05,-1.41 2.95,-1.12 2.95,-1.12c0.58,1.52 0.21,2.63 0.1,2.91c0.68,0.76 1.1,1.74 1.1,2.94c0,4.21 -2.51,5.15 -4.91,5.42c0.39,0.34 0.73,1.02 0.73,2.06v3.06c0,0.29 0.19,0.63 0.74,0.52c4.25,-1.42 7.32,-5.45 7.32,-10.2C22,6.58 17.52,2 12,2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12.87,15.07L10.33,12.56L10.36,12.53C12.1,10.59 13.34,8.36 14.07,6H17V4H10V2H8V4H1V6H12.17C11.5,7.92 10.45,9.75 9,11.35C8.06,10.32 7.28,9.19 6.69,8H4.69C5.42,9.63 6.42,11.17 7.7,12.56L2.61,17.58L4,19L9,14L12.11,17.11L12.87,15.07ZM18.5,10H16.5L12,22H14L15.12,19H19.87L21,22H23L18.5,10ZM15.88,17L17.5,12.67L19.12,17H15.88Z" />
|
||||
</vector>
|
||||
@@ -100,8 +100,8 @@
|
||||
<string name="server_lab_reserved">Reserved (اختياري، مفصول بفواصل)</string>
|
||||
<string name="server_lab_local_address">العنوان المحلي (اختياري IPv4/IPv6، مفصولة بفواصل)</string>
|
||||
<string name="server_lab_local_mtu">MTU (اختياري، الافتراضي 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي في النواة 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي في النواة 50)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي 50 مللي ثانية)</string>
|
||||
<string name="toast_success">نجاح</string>
|
||||
<string name="toast_failure">فشل</string>
|
||||
<string name="toast_none_data">لا يوجد شيء</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">إعدادات VPN</string>
|
||||
<string name="title_pref_per_app_proxy">الوكيل لكل تطبيق</string>
|
||||
<string name="summary_pref_per_app_proxy">عام: تستخدم التطبيقات المحددة الوكيل، وتتصل التطبيقات غير المحددة مباشرة؛\nوضع التجاوز: تتصل التطبيقات المحددة مباشرة، وتستخدم التطبيقات غير المحددة الوكيل.\nيتوفر في القائمة خيار لتحديد التطبيقات التي ستستخدم الوكيل تلقائيًا.</string>
|
||||
<string name="title_pref_is_booted">الاتصال تلقائياً عند بدء التشغيل</string>
|
||||
<string name="summary_pref_is_booted">الاتصال تلقائياً بالخادم المحدد عند بدء التشغيل؛ قد لا تنجح المحاولة</string>
|
||||
<string name="title_pref_is_booted">الاتصال بعد إقلاع الجهاز</string>
|
||||
<string name="summary_pref_is_booted">الاتصال تلقائياً بالخادم المحدد بعد إقلاع الجهاز. قد يقيّد Android بدء التشغيل في الخلفية.</string>
|
||||
<string name="title_system_vpn_settings">إعدادات VPN للنظام</string>
|
||||
<string name="summary_system_vpn_settings">فتح إعدادات VPN في Android لإعداد VPN الدائم، إذا كان الجهاز يدعمه.</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">إعدادات VPN للنظام غير متاحة.</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">ستعمل المهمة في الخلفية، وسيُعرض تقدمها في الإشعار.</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">حول التطبيق</string>
|
||||
<string name="title_source_code">الكود المصدري</string>
|
||||
<string name="title_oss_license">تراخيص المصادر المفتوحة</string>
|
||||
<string name="title_translators">المترجمون</string>
|
||||
<string name="title_tg_channel">قناة Telegram</string>
|
||||
|
||||
<string name="title_pref_promotion">ترويج</string>
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
<string name="server_lab_reserved">Reserved (ঐচ্ছিক, কমা দিয়ে পৃথক)</string>
|
||||
<string name="server_lab_local_address">স্থানীয় ঠিকানা (ঐচ্ছিক IPv4/IPv6, কমা দ্বারা পৃথক করা)</string>
|
||||
<string name="server_lab_local_mtu">MTU (ঐচ্ছিক, ডিফল্ট 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, কোর ডিফল্ট 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, কোর ডিফল্ট 50)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, ডিফল্ট 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, ডিফল্ট 50 মিলিসেকেন্ড)</string>
|
||||
<string name="toast_success">সফল</string>
|
||||
<string name="toast_failure">ব্যর্থ</string>
|
||||
<string name="toast_none_data">কোনও তথ্য নেই</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">VPN সেটিংস</string>
|
||||
<string name="title_pref_per_app_proxy">প্রতি-অ্যাপ প্রক্সি</string>
|
||||
<string name="summary_pref_per_app_proxy">সাধারণ: নির্বাচিত অ্যাপ প্রক্সি ব্যবহার করে, অনির্বাচিত অ্যাপ সরাসরি সংযুক্ত হয়;\nবাইপাস মোড: নির্বাচিত অ্যাপ সরাসরি সংযুক্ত হয়, অনির্বাচিত অ্যাপ প্রক্সি ব্যবহার করে।\nযেসব অ্যাপ প্রক্সি ব্যবহার করবে, সেগুলি স্বয়ংক্রিয়ভাবে নির্বাচনের বিকল্পটি মেনুতে আছে।</string>
|
||||
<string name="title_pref_is_booted">চালুর সময় স্বয়ংক্রিয় সংযোগ</string>
|
||||
<string name="summary_pref_is_booted">চালুর সময় নির্বাচিত সার্ভারে স্বয়ংক্রিয়ভাবে সংযোগ করে; সংযোগ ব্যর্থ হতে পারে</string>
|
||||
<string name="title_pref_is_booted">ডিভাইস চালু হওয়ার পর সংযোগ</string>
|
||||
<string name="summary_pref_is_booted">ডিভাইস চালু হওয়ার পর নির্বাচিত সার্ভারে স্বয়ংক্রিয়ভাবে সংযোগ করুন। Android ব্যাকগ্রাউন্ডে চালু হওয়া সীমিত করতে পারে।</string>
|
||||
<string name="title_system_vpn_settings">সিস্টেমের VPN সেটিংস</string>
|
||||
<string name="summary_system_vpn_settings">ডিভাইসে সমর্থিত হলে সর্বদা চালু VPN কনফিগার করতে Android-এর VPN সেটিংস খুলুন।</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">সিস্টেমের VPN সেটিংস উপলভ্য নয়।</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">কাজটি ব্যাকগ্রাউন্ডে চলবে এবং অগ্রগতির তথ্য বিজ্ঞপ্তিতে দেখানো হবে।</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">সম্পর্কিত</string>
|
||||
<string name="title_source_code">সোর্স কোড</string>
|
||||
<string name="title_oss_license">ওপেন সোর্স লাইসেন্স</string>
|
||||
<string name="title_translators">অনুবাদকগণ</string>
|
||||
<string name="title_tg_channel">টেলিগ্রাম চ্যানেল</string>
|
||||
|
||||
<string name="title_pref_promotion">প্রচার</string>
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
<string name="server_lab_reserved">Reserved(اختیاری، وا کاما (,) ز یک جوڌا ابۊن)</string>
|
||||
<string name="server_lab_local_address">نشۊوی محلی (اختیاری IPv4/IPv6، وا کاما (,) ز یک جوڌا ابۊن)</string>
|
||||
<string name="server_lab_local_mtu">MTU(اختیاری، پؽش فرض 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرض هسته 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرض هسته 50)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرض 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرض 50 میلیثانیه)</string>
|
||||
<string name="toast_success">وا مووفقیت ٱنجوم وابی</string>
|
||||
<string name="toast_failure">شکست خرد</string>
|
||||
<string name="toast_none_data">هیچ داڌه ای وۊجۊڌ نڌاره</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">سامووا VPN</string>
|
||||
<string name="title_pref_per_app_proxy">پروکسی و ری برنومه</string>
|
||||
<string name="summary_pref_per_app_proxy">پوی وولاتی: برنومه پسند وابیڌه وا ی پروکسی منپیز ابۊ، برنومه پسند نوابیڌه موستقیمن منپیز ابۊ.\nحالت دور زیڌن: برنومه پسند وابیڌه موستقیمن منپیز ابۊ، برنومه پسند نوابیڌه وا ی پروکسی منپیز ابۊ.\nپسند خوتکار برنومه یلی ک وا ی پروکسی منپیز ابۊن من نومگه امکووݩ پذیر هڌ.</string>
|
||||
<string name="title_pref_is_booted">منپیز خوتکار مجال ره ونی</string>
|
||||
<string name="summary_pref_is_booted">مجال ره وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ.</string>
|
||||
<string name="title_pref_is_booted">منپیز بئڌ ره وندن دسگا</string>
|
||||
<string name="summary_pref_is_booted">بئڌ ره وندن دسگا، خوساخوس و سرور پسند بیڌه منپیز ابۊ. Android گاشڌ ره وندن من پس زمینه ن مئدۊد کونه.</string>
|
||||
<string name="title_system_vpn_settings">سامووا VPN سیستوم</string>
|
||||
<string name="summary_system_vpn_settings">سامووا VPN من Android ن وا کۊنین سی ساموو کردن VPN همیشه ره وست، ٱر دسگا زس پشتیبانی اکونه.</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">سامووا VPN سیستوم من دسرس نین.</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">وزیفه من پس زمینه کار اکونه وو ایلاعات پؽشرفت من اعلان نشووݩ داڌه ابۊ.</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">زبار</string>
|
||||
<string name="title_source_code">کود بونچک</string>
|
||||
<string name="title_oss_license">موجوزا کود بونچک</string>
|
||||
<string name="title_translators">ترجومهگرا</string>
|
||||
<string name="title_tg_channel">تورگه تلگرام</string>
|
||||
|
||||
<string name="title_pref_promotion">تبلیغات</string>
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
<string name="server_lab_reserved">Reserved (اختیاری، جدا شده با کاما)</string>
|
||||
<string name="server_lab_local_address">آدرس محلی (IPv4/IPv6 اختیاری، جدا شده با کاما)</string>
|
||||
<string name="server_lab_local_mtu">MTU (اختیاری، پیشفرض 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیشفرض هسته 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیشفرض هسته 50)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیشفرض 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیشفرض 50 میلیثانیه)</string>
|
||||
<string name="toast_success">موفقیتآمیز</string>
|
||||
<string name="toast_failure">شکست!</string>
|
||||
<string name="toast_none_data">دادهای یافت نشد</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">تنظیمات VPN</string>
|
||||
<string name="title_pref_per_app_proxy">پروکسی به تفکیک برنامه</string>
|
||||
<string name="summary_pref_per_app_proxy">حالت عادی: برنامههای انتخابشده از پروکسی استفاده میکنند و برنامههای انتخابنشده مستقیماً متصل میشوند؛\nحالت دور زدن: برنامههای انتخابشده مستقیماً متصل میشوند و برنامههای انتخابنشده از پروکسی استفاده میکنند.\nگزینهٔ انتخاب خودکار برنامههایی که باید از پروکسی استفاده کنند در منو قرار دارد.</string>
|
||||
<string name="title_pref_is_booted">اتصال خودکار هنگام راه اندازی</string>
|
||||
<string name="summary_pref_is_booted">هنگام راه اندازی به طور خودکار به سرور انتخابی متصل می شود که ممکن است ناموفق باشد.</string>
|
||||
<string name="title_pref_is_booted">اتصال پس از راهاندازی دستگاه</string>
|
||||
<string name="summary_pref_is_booted">پس از راهاندازی دستگاه، بهطور خودکار به سرور انتخابشده متصل شود. ممکن است Android اجرای پسزمینه را محدود کند.</string>
|
||||
<string name="title_system_vpn_settings">تنظیمات VPN سیستم</string>
|
||||
<string name="summary_system_vpn_settings">تنظیمات VPN در Android را برای پیکربندی VPN همیشهروشن باز کنید، اگر دستگاه از آن پشتیبانی میکند.</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">تنظیمات VPN سیستم در دسترس نیست.</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">این کار در پسزمینه اجرا میشود و پیشرفت آن در اعلان نمایش داده خواهد شد.</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">درباره</string>
|
||||
<string name="title_source_code">کد منبع</string>
|
||||
<string name="title_oss_license">مجوز های منبع باز</string>
|
||||
<string name="title_translators">مترجمان</string>
|
||||
<string name="title_tg_channel">کانال تلگرام</string>
|
||||
|
||||
<string name="title_pref_promotion">تبلیغات</string>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
|
||||
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (необязательно, по умолчанию 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50 мс)</string>
|
||||
<string name="toast_success">Успешно</string>
|
||||
<string name="toast_failure">Ошибка</string>
|
||||
<string name="toast_none_data">Ничего нет</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">Настройки VPN</string>
|
||||
<string name="title_pref_per_app_proxy">Прокси для выбранных приложений</string>
|
||||
<string name="summary_pref_per_app_proxy">Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nРежим обхода: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nВ меню можно автоматически выбрать приложения, которые должны использовать прокси.</string>
|
||||
<string name="title_pref_is_booted">Автоподключение при запуске</string>
|
||||
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным)</string>
|
||||
<string name="title_pref_is_booted">Подключаться после загрузки устройства</string>
|
||||
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу после загрузки устройства. Android может ограничивать запуск в фоне.</string>
|
||||
<string name="title_system_vpn_settings">Системные настройки VPN</string>
|
||||
<string name="summary_system_vpn_settings">Открыть настройки VPN в Android для включения постоянного VPN-подключения, если устройство его поддерживает.</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">Системные настройки VPN недоступны.</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">Задача будет выполняться в фоновом режиме, информация о ходе выполнения будет отображаться в уведомлении.</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">О приложении</string>
|
||||
<string name="title_source_code">Исходный код</string>
|
||||
<string name="title_oss_license">Лицензии открытого исходного кода</string>
|
||||
<string name="title_translators">Переводчики</string>
|
||||
<string name="title_tg_channel">Telegram-канал</string>
|
||||
|
||||
<string name="title_pref_promotion">Предложения</string>
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
<string name="server_lab_reserved">Reserved (không bắt buộc, phân cách bằng dấu phẩy)</string>
|
||||
<string name="server_lab_local_address">Địa chỉ cục bộ (IPv4/IPv6 không bắt buộc, phân cách bằng dấu phẩy)</string>
|
||||
<string name="server_lab_local_mtu">MTU (không bắt buộc, mặc định 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (không bắt buộc, mặc định của core là 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (không bắt buộc, mặc định của core là 50)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (không bắt buộc, mặc định 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (không bắt buộc, mặc định 50 ms)</string>
|
||||
<string name="toast_success">Thành công!</string>
|
||||
<string name="toast_failure">Đã xảy ra lỗi, vui lòng thử lại!</string>
|
||||
<string name="toast_none_data">Không có gì ở đây!</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">Cài đặt VPN</string>
|
||||
<string name="title_pref_per_app_proxy">Proxy theo từng ứng dụng</string>
|
||||
<string name="summary_pref_per_app_proxy">Thông thường: ứng dụng đã chọn dùng proxy, ứng dụng chưa chọn kết nối trực tiếp;\nChế độ bỏ qua: ứng dụng đã chọn kết nối trực tiếp, ứng dụng chưa chọn dùng proxy.\nTrong trình đơn có tùy chọn tự động chọn các ứng dụng sẽ dùng proxy.</string>
|
||||
<string name="title_pref_is_booted">Tự động kết nối khi khởi động</string>
|
||||
<string name="summary_pref_is_booted">Tự động kết nối với máy chủ đã chọn khi khởi động; thao tác này có thể không thành công</string>
|
||||
<string name="title_pref_is_booted">Kết nối sau khi thiết bị khởi động</string>
|
||||
<string name="summary_pref_is_booted">Tự động kết nối với máy chủ đã chọn sau khi thiết bị khởi động. Android có thể hạn chế khởi chạy trong nền.</string>
|
||||
<string name="title_system_vpn_settings">Cài đặt VPN hệ thống</string>
|
||||
<string name="summary_system_vpn_settings">Mở cài đặt VPN của Android để cấu hình VPN luôn bật, nếu thiết bị này hỗ trợ.</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">Không có cài đặt VPN hệ thống.</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">Tác vụ sẽ chạy trong nền và thông tin tiến độ sẽ hiển thị trong thông báo.</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">Giới thiệu</string>
|
||||
<string name="title_source_code">Mã nguồn</string>
|
||||
<string name="title_oss_license">Giấy phép mã nguồn mở</string>
|
||||
<string name="title_translators">Người dịch</string>
|
||||
<string name="title_tg_channel">Kênh Telegram</string>
|
||||
|
||||
<string name="title_pref_promotion">Ưu đãi</string>
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
<string name="server_lab_reserved">Reserved (可选,逗号隔开)</string>
|
||||
<string name="server_lab_local_address">本地地址 (可选 IPv4/IPv6,逗号隔开)</string>
|
||||
<string name="server_lab_local_mtu">MTU(可选,默认 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU(可选,核心默认 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI(可选,核心默认 50)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU(可选,默认 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI(可选,默认 50 毫秒)</string>
|
||||
<string name="toast_success">成功</string>
|
||||
<string name="toast_failure">失败</string>
|
||||
<string name="toast_none_data">没有数据</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">VPN 设置</string>
|
||||
<string name="title_pref_per_app_proxy">分应用代理</string>
|
||||
<string name="summary_pref_per_app_proxy">常规:勾选的应用使用代理,未勾选的应用直连;\n绕行模式:勾选的应用直连,未勾选的应用使用代理。\n菜单中可自动选择需要使用代理的应用。</string>
|
||||
<string name="title_pref_is_booted">开机时自动连接</string>
|
||||
<string name="summary_pref_is_booted">开机时自动连接选择的服务器,可能会不成功</string>
|
||||
<string name="title_pref_is_booted">设备启动后自动连接</string>
|
||||
<string name="summary_pref_is_booted">设备启动后自动连接到所选服务器。Android 可能会限制后台启动。</string>
|
||||
<string name="title_system_vpn_settings">系统 VPN 设置</string>
|
||||
<string name="summary_system_vpn_settings">打开 Android VPN 设置,配置始终开启的 VPN(如果此设备支持)。</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">系统 VPN 设置不可用。</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">任务将在后台运行,在通知中会显示进度信息</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">关于</string>
|
||||
<string name="title_source_code">源代码</string>
|
||||
<string name="title_oss_license">开源许可证</string>
|
||||
<string name="title_translators">翻译贡献者</string>
|
||||
<string name="title_tg_channel">Telegram 频道</string>
|
||||
|
||||
<string name="title_pref_promotion">推广</string>
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
<string name="server_lab_reserved">Reserved (可選,逗號隔開)</string>
|
||||
<string name="server_lab_local_address">本機位址 (可選 IPv4/IPv6,逗號隔開)</string>
|
||||
<string name="server_lab_local_mtu">MTU(可選,預設 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU(可選,核心預設 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI(可選,核心預設 50)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU(可選,預設 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI(可選,預設 50 毫秒)</string>
|
||||
<string name="toast_success">成功</string>
|
||||
<string name="toast_failure">失敗</string>
|
||||
<string name="toast_none_data">無資料</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">VPN 設定</string>
|
||||
<string name="title_pref_per_app_proxy">個別應用程式代理</string>
|
||||
<string name="summary_pref_per_app_proxy">一般:已勾選的應用程式使用代理,未勾選的應用程式直接連線;\n繞行模式:已勾選的應用程式直接連線,未勾選的應用程式使用代理。\n選單中可自動選取需要使用代理的應用程式。</string>
|
||||
<string name="title_pref_is_booted">開機時自動連線</string>
|
||||
<string name="summary_pref_is_booted">開機時自動連線選擇的伺服器,可能會不成功</string>
|
||||
<string name="title_pref_is_booted">裝置開機後自動連線</string>
|
||||
<string name="summary_pref_is_booted">裝置開機後自動連線至所選伺服器。Android 可能會限制背景啟動。</string>
|
||||
<string name="title_system_vpn_settings">系統 VPN 設定</string>
|
||||
<string name="summary_system_vpn_settings">開啟 Android VPN 設定,設定永遠開啟的 VPN(若此裝置支援)。</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">系統 VPN 設定無法使用。</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">任務將在背景執行,通知中會顯示進度資訊。</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">關於</string>
|
||||
<string name="title_source_code">原始碼</string>
|
||||
<string name="title_oss_license">開放原始碼授權</string>
|
||||
<string name="title_translators">翻譯貢獻者</string>
|
||||
<string name="title_tg_channel">Telegram 頻道</string>
|
||||
|
||||
<string name="title_pref_promotion">推廣</string>
|
||||
|
||||
@@ -100,8 +100,8 @@
|
||||
<string name="server_lab_reserved">Reserved (optional, separated by commas)</string>
|
||||
<string name="server_lab_local_address">Local address (optional IPv4/IPv6, separated by commas)</string>
|
||||
<string name="server_lab_local_mtu">MTU (optional, default 1420)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (optional, core default 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (optional, core default 50)</string>
|
||||
<string name="server_lab_kcp_mtu">mKCP MTU (optional, default 1350)</string>
|
||||
<string name="server_lab_kcp_tti">mKCP TTI (optional, default 50 ms)</string>
|
||||
<string name="toast_success">Success</string>
|
||||
<string name="toast_failure">Failure</string>
|
||||
<string name="toast_none_data">No data available</string>
|
||||
@@ -172,8 +172,11 @@
|
||||
<string name="title_vpn_settings">VPN Settings</string>
|
||||
<string name="title_pref_per_app_proxy">Per-app proxy</string>
|
||||
<string name="summary_pref_per_app_proxy">General: checked apps use the proxy, and unchecked apps connect directly;\nBypass mode: checked apps connect directly, and unchecked apps use the proxy.\nThe option to automatically select apps that should use the proxy is in the menu.</string>
|
||||
<string name="title_pref_is_booted">Auto connect at startup</string>
|
||||
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
|
||||
<string name="title_pref_is_booted">Connect after device boot</string>
|
||||
<string name="summary_pref_is_booted">Automatically connect to the selected server after the device starts. Android may restrict background starts.</string>
|
||||
<string name="title_system_vpn_settings">System VPN settings</string>
|
||||
<string name="summary_system_vpn_settings">Open Android VPN settings to configure Always-on VPN, if supported by this device.</string>
|
||||
<string name="toast_system_vpn_settings_unavailable">System VPN settings are unavailable.</string>
|
||||
|
||||
|
||||
<string name="subscription_updater_job_tips">The task will run in the background, and progress information will be displayed in the notification.</string>
|
||||
@@ -283,6 +286,7 @@
|
||||
<string name="title_about">About</string>
|
||||
<string name="title_source_code">Source code</string>
|
||||
<string name="title_oss_license">Open-source licenses</string>
|
||||
<string name="title_translators">Translators</string>
|
||||
<string name="title_tg_channel">Telegram channel</string>
|
||||
|
||||
<string name="title_pref_promotion">Promotion</string>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.util.Log
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.mockStatic
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.reset
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
|
||||
class SubscriptionIndexTest {
|
||||
private val mainValues = mutableMapOf<String, String>()
|
||||
private val subValues = mutableMapOf<String, String>()
|
||||
|
||||
@Before
|
||||
fun prepareStorage() {
|
||||
for ((storage, values) in listOf(main to mainValues, subs to subValues)) {
|
||||
reset(storage)
|
||||
whenever(storage.decodeString(any())).thenAnswer { values[it.getArgument<String>(0)] }
|
||||
whenever(storage.encode(any<String>(), any<String>())).thenAnswer {
|
||||
values[it.getArgument(0)] = it.getArgument(1)
|
||||
true
|
||||
}
|
||||
whenever(storage.allKeys()).thenAnswer { values.keys.toTypedArray() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateIdsKeepTheirFirstPositionWithoutWritingStorage() {
|
||||
val stored = """["second","first","second","third","first"]"""
|
||||
mainValues["SUB_IDS"] = stored
|
||||
|
||||
assertEquals(listOf("second", "first", "third"), MmkvManager.decodeSubsList())
|
||||
assertEquals(stored, mainValues["SUB_IDS"])
|
||||
verify(main, never()).encode(any<String>(), any<String>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateIndexEntriesProduceOnlyOneSubscriptionRow() {
|
||||
mainValues["SUB_IDS"] = """["b","a","b","a"]"""
|
||||
subValues["a"] = JsonUtil.toJson(SubscriptionItem(remarks = "Alpha"))
|
||||
subValues["b"] = JsonUtil.toJson(SubscriptionItem(remarks = "Beta"))
|
||||
|
||||
val rows = MmkvManager.decodeSubscriptions()
|
||||
|
||||
assertEquals(listOf("b", "a"), rows.map { it.guid })
|
||||
assertEquals(listOf("Beta", "Alpha"), rows.map { it.subscription.remarks })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedAndBlankNamesDoNotMergeDifferentSubscriptions() {
|
||||
mainValues["SUB_IDS"] = """["a","b","c","d"]"""
|
||||
listOf("a" to "Same", "b" to "Same", "c" to "", "d" to " ").forEach { (id, name) ->
|
||||
subValues[id] = JsonUtil.toJson(SubscriptionItem(remarks = name))
|
||||
}
|
||||
|
||||
assertEquals(listOf("a", "b", "c", "d"), MmkvManager.decodeSubscriptions().map { it.guid })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodedIndexRemainsMutableAndCanBeSavedInANewOrder() {
|
||||
mainValues["SUB_IDS"] = """["a","b","a"]"""
|
||||
val ids = MmkvManager.decodeSubsList()
|
||||
ids.remove("a")
|
||||
ids.add(0, "c")
|
||||
MmkvManager.encodeSubsList(ids)
|
||||
|
||||
assertEquals("""["c","b"]""", mainValues["SUB_IDS"])
|
||||
assertEquals(listOf("c", "b"), MmkvManager.decodeSubsList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingBlankAndEmptyIndexesRemainEmpty() {
|
||||
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
|
||||
listOf("", " ", "[]", "null").forEach { stored ->
|
||||
mainValues["SUB_IDS"] = stored
|
||||
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedIndexKeepsTheExistingEmptyFallback() {
|
||||
mockStatic(Log::class.java).use {
|
||||
mainValues["SUB_IDS"] = "{"
|
||||
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val main: MMKV = mock()
|
||||
private val subs: MMKV = mock()
|
||||
private val settings: MMKV = mock()
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun initializeHandles() {
|
||||
mockStatic(MMKV::class.java).use {
|
||||
it.`when`<MMKV> { MMKV.mmkvWithID("MAIN", MMKV.MULTI_PROCESS_MODE) }.thenReturn(main)
|
||||
it.`when`<MMKV> { MMKV.mmkvWithID("SUB", MMKV.MULTI_PROCESS_MODE) }.thenReturn(subs)
|
||||
it.`when`<MMKV> { MMKV.mmkvWithID("SETTING", MMKV.MULTI_PROCESS_MODE) }.thenReturn(settings)
|
||||
MmkvManager.decodeSubscriptions()
|
||||
MmkvManager.decodeSettingsString("test-initialize")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.v2ray.ang.root
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RootProxyManagerTest {
|
||||
|
||||
@Test
|
||||
fun `single quotes are escaped in YAML scalars`() {
|
||||
assertEquals("'user''name'", "user'name".toSingleQuotedYamlScalar())
|
||||
assertEquals("'pa''''ss'", "pa''ss".toSingleQuotedYamlScalar())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiline credentials remain inside the YAML scalar`() {
|
||||
val credential = "user\nHEVCFG\ntouch /data/local/tmp/injected"
|
||||
|
||||
assertEquals("'user\nHEVCFG\ntouch /data/local/tmp/injected'", credential.toSingleQuotedYamlScalar())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.v2ray.ang.ui.logcat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LogcatEntryTest {
|
||||
@Test
|
||||
fun emptyLogProducesNoEntries() {
|
||||
assertTrue(createLogcatEntries(emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun identicalTimestampedLinesHaveUniqueKeysWithoutLosingMessages() {
|
||||
val line = "08-31 04:32:41.187 I/GoLog (42): repeated message"
|
||||
val logs = listOf(line, line, line)
|
||||
val entries = createLogcatEntries(logs)
|
||||
|
||||
assertEquals(logs, entries.map { it.text })
|
||||
assertEquals(3, entries.map { it.key }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesNewestFirstOrderAndOriginalText() {
|
||||
val logs = listOf("newest", "\tat repeatedFrame(File.kt:42)", "middle", "\tat repeatedFrame(File.kt:42)")
|
||||
val entries = createLogcatEntries(logs)
|
||||
|
||||
assertEquals(logs, entries.map { it.text })
|
||||
assertEquals(logs.joinToString("\n"), entries.joinToString("\n") { it.text })
|
||||
assertEquals(logs.size, entries.map { it.key }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filteringOtherLinesPreservesKeysOfEveryMatchingOccurrence() {
|
||||
val logs = listOf("match", "other", "match", "last")
|
||||
val entries = createLogcatEntries(logs)
|
||||
val filtered = createLogcatEntries(logs.filter { it.contains("match") })
|
||||
|
||||
assertEquals(entries.filter { it.text.contains("match") }, filtered)
|
||||
assertTrue(createLogcatEntries(logs.filter { it.contains("missing") }).isEmpty())
|
||||
assertEquals(entries, createLogcatEntries(logs))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshWithNewerLinesPreservesExistingOccurrenceKeys() {
|
||||
val logs = listOf("repeated", "older", "repeated")
|
||||
val entries = createLogcatEntries(logs)
|
||||
val refreshed = createLogcatEntries(listOf("newest", "repeated") + logs)
|
||||
|
||||
assertEquals(entries, refreshed.takeLast(entries.size))
|
||||
assertEquals(refreshed.size, refreshed.map { it.key }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyAndKeyLikeMessagesRemainDistinct() {
|
||||
val logs = listOf("", "0:", "", "1:message", "message", "message")
|
||||
val entries = createLogcatEntries(logs)
|
||||
|
||||
assertEquals(logs, entries.map { it.text })
|
||||
assertEquals(logs.size, entries.map { it.key }.distinct().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.v2ray.ang.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.Settings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito.mockConstruction
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.whenever
|
||||
|
||||
class SettingsViewModelTest {
|
||||
@Test
|
||||
fun vpnSettingsVisibilityFollowsActivityAvailability() = runBlocking(Dispatchers.IO) {
|
||||
val packageManager = mock<PackageManager>()
|
||||
val application = mock<Application>()
|
||||
whenever(application.packageManager).thenReturn(packageManager)
|
||||
var activity: ComponentName? = null
|
||||
|
||||
mockConstruction(Intent::class.java) { intent, context ->
|
||||
assertEquals(listOf(Settings.ACTION_VPN_SETTINGS), context.arguments())
|
||||
whenever(intent.resolveActivity(packageManager)).thenAnswer { activity }
|
||||
}.use {
|
||||
val viewModel = SettingsViewModel(application)
|
||||
assertFalse(viewModel.systemVpnSettingsAvailable.value)
|
||||
viewModel.refreshSystemVpnSettingsAvailability()
|
||||
assertFalse(viewModel.systemVpnSettingsAvailable.value)
|
||||
|
||||
activity = mock<ComponentName>()
|
||||
viewModel.refreshSystemVpnSettingsAvailability()
|
||||
assertTrue(viewModel.systemVpnSettingsAvailable.value)
|
||||
|
||||
activity = null
|
||||
viewModel.refreshSystemVpnSettingsAvailability()
|
||||
assertFalse(viewModel.systemVpnSettingsAvailable.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.v2ray.ang.util
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipException
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class ZipUtilTest {
|
||||
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun unzipToFolderExtractsNestedFile() {
|
||||
val archive = createArchive("nested/config" to "value".toByteArray())
|
||||
val destination = File(temporaryFolder.root, "destination")
|
||||
|
||||
assertTrue(ZipUtil.unzipToFolder(archive, destination.absolutePath))
|
||||
assertEquals("value", File(destination, "nested/config").readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractArchiveRejectsPathTraversal() {
|
||||
val archive = createArchive("../outside" to "overwrite".toByteArray())
|
||||
val outside = File(temporaryFolder.root, "outside")
|
||||
|
||||
assertArchiveRejected(archive)
|
||||
|
||||
assertFalse(outside.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractArchiveRejectsOversizedArchive() {
|
||||
val archive = createArchive("config" to byteArrayOf(1))
|
||||
|
||||
assertArchiveRejected(
|
||||
archive,
|
||||
extractionLimits(maxArchiveBytes = archive.length() - 1),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractArchiveRejectsTooManyEntries() {
|
||||
val archive = createArchive(
|
||||
"first" to byteArrayOf(1),
|
||||
"second" to byteArrayOf(2),
|
||||
)
|
||||
assertArchiveRejected(archive, extractionLimits(maxEntries = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractArchiveRejectsOversizedEntry() {
|
||||
val archive = createArchive("config" to ByteArray(5))
|
||||
|
||||
assertArchiveRejected(archive, extractionLimits(maxEntryBytes = 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractArchiveRejectsExcessiveTotalSize() {
|
||||
val archive = createArchive(
|
||||
"first" to ByteArray(3),
|
||||
"second" to ByteArray(3),
|
||||
)
|
||||
assertArchiveRejected(archive, extractionLimits(maxTotalBytes = 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractArchiveRejectsExcessiveCompressionRatio() {
|
||||
val archive = createArchive("config" to ByteArray(4096))
|
||||
|
||||
assertArchiveRejected(archive, extractionLimits(maxCompressionRatio = 2.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractArchiveRejectsDuplicateCanonicalDestination() {
|
||||
val archive = createArchive(
|
||||
"config" to byteArrayOf(1),
|
||||
"nested/../config" to byteArrayOf(2),
|
||||
)
|
||||
assertArchiveRejected(archive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractArchiveRejectsEntryBelowFile() {
|
||||
val archive = createArchive(
|
||||
"parent" to byteArrayOf(1),
|
||||
"parent/child" to byteArrayOf(2),
|
||||
)
|
||||
assertArchiveRejected(archive)
|
||||
}
|
||||
|
||||
private fun assertArchiveRejected(
|
||||
archive: File,
|
||||
limits: ZipUtil.ExtractionLimits = extractionLimits(),
|
||||
) {
|
||||
val destination = File(temporaryFolder.root, "destination")
|
||||
assertThrows(ZipException::class.java) {
|
||||
ZipUtil.extractArchive(archive, destination, limits)
|
||||
}
|
||||
assertFalse(destination.exists())
|
||||
}
|
||||
|
||||
private fun createArchive(vararg entries: Pair<String, ByteArray>): File {
|
||||
val archive = temporaryFolder.newFile()
|
||||
ZipOutputStream(archive.outputStream()).use { output ->
|
||||
entries.forEach { (name, contents) ->
|
||||
output.putNextEntry(ZipEntry(name))
|
||||
output.write(contents)
|
||||
output.closeEntry()
|
||||
}
|
||||
}
|
||||
return archive
|
||||
}
|
||||
|
||||
private fun extractionLimits(
|
||||
maxArchiveBytes: Long = 1024L * 1024L,
|
||||
maxEntries: Int = 10,
|
||||
maxEntryBytes: Long = 16L * 1024L,
|
||||
maxTotalBytes: Long = 32L * 1024L,
|
||||
maxCompressionRatio: Double = 1000.0,
|
||||
) = ZipUtil.ExtractionLimits(
|
||||
maxArchiveBytes = maxArchiveBytes,
|
||||
maxEntries = maxEntries,
|
||||
maxEntryBytes = maxEntryBytes,
|
||||
maxTotalBytes = maxTotalBytes,
|
||||
maxCompressionRatio = maxCompressionRatio,
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "9.3.1"
|
||||
agp = "9.3.2"
|
||||
desugarJdkLibs = "2.1.5"
|
||||
gradleLicensePlugin = "0.9.9"
|
||||
kotlin = "2.4.10"
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# v2rayNG — Agent guide
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cd V2rayNG
|
||||
./gradlew assembleFdroidDebug # or assemblePlaystoreDebug
|
||||
```
|
||||
|
||||
Kotlin 2.4.0, AGP 9.2.1, Gradle Kotlin DSL. No lint/format/typecheck tasks configured.
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
cd V2rayNG && ./gradlew test
|
||||
```
|
||||
|
||||
JUnit 4 + Mockito. Unit tests only under `app/src/test/java/`. No instrumented tests beyond defaults.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
V2rayNG/
|
||||
app/
|
||||
src/main/java/com/v2ray/ang/
|
||||
AngApplication.kt # extends MultiDexApplication, inits MMKV + WorkManager
|
||||
AppConfig.kt # all constants, pref keys, URLs, ports, tags
|
||||
core/ # v2ray core integration
|
||||
CoreServiceManager.kt # start/stop core, traffic stats, delay measurement
|
||||
CoreConfigManager.kt # generates JSON config for v2ray core
|
||||
CoreNativeManager.kt # JNI bridge to libv2ray AAR
|
||||
CoreOutboundBuilder.kt # outbound config construction
|
||||
CoreConfigContextBuilder.kt
|
||||
service/ # Android foreground services
|
||||
CoreVpnService.kt # VPN mode (VpnService)
|
||||
CoreProxyOnlyService.kt # proxy-only mode (no VPN)
|
||||
CoreTestService.kt # delay test
|
||||
TProxyService.kt
|
||||
DialerNativeService.kt / DialerWebviewService.kt # browser dialer
|
||||
RealPingWorkerService.kt # WorkManager-based real ping
|
||||
QSTileService.kt # quick settings tile
|
||||
ProcessService.kt
|
||||
handler/ # business logic
|
||||
MmkvManager.kt # all MMKV CRUD (servers, subs, settings, routing)
|
||||
SettingsManager.kt # preference defaults, config generation (633 lines)
|
||||
AngConfigManager.kt # server config operations
|
||||
NotificationManager.kt
|
||||
SpeedtestManager.kt
|
||||
SubscriptionUpdater.kt
|
||||
WebDavManager.kt
|
||||
UpdateCheckerManager.kt
|
||||
CertificateFingerprintManager.kt
|
||||
SettingsChangeManager.kt
|
||||
ui/ # activities, adapters, fragments
|
||||
MainActivity.kt # main screen with drawer + tabs
|
||||
ServerActivity.kt # edit server config
|
||||
ServerCustomConfigActivity.kt / ServerGroupActivity.kt / ServerProxyChainActivity.kt
|
||||
SettingsActivity.kt / PerAppProxyActivity.kt / AppPickerActivity.kt
|
||||
ScannerActivity.kt / LogcatActivity.kt
|
||||
RoutingSettingActivity.kt / RoutingEditActivity.kt
|
||||
SubSettingActivity.kt / SubEditActivity.kt
|
||||
UserAssetActivity.kt / UserAssetUrlActivity.kt
|
||||
TaskerActivity.kt / UrlSchemeActivity.kt
|
||||
BackupActivity.kt / CheckUpdateActivity.kt / AboutActivity.kt
|
||||
fmt/ # protocol URL parsers (VMESS, VLESS, TROJAN, SS, SOCKS, etc.)
|
||||
dto/ # data classes + entities/
|
||||
enums/ # EConfigType, Language, RoutingType, etc.
|
||||
extension/_Ext.kt # extension functions (toast, traffic string, etc.)
|
||||
util/ # Utils, JsonUtil, HttpUtil, LogUtil, etc.
|
||||
receiver/ # BootReceiver, TaskerReceiver, WidgetProvider
|
||||
contracts/ # interfaces (ServiceControl, Tun2SocksControl)
|
||||
helper/ # QRCodeScannerHelper, PermissionHelper, FileChooserHelper etc.
|
||||
```
|
||||
|
||||
## Key facts
|
||||
|
||||
- **Storage**: MMKV exclusively — never SharedPreferences. `MmkvManager` is the data layer.
|
||||
- **Core**: Native AAR (`libv2ray`) from [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite). Prebuilt `.aar` files go in `app/libs/`.
|
||||
- **Services** run in dedicated process `:RunSoLibV2RayDaemon`. `CoreServiceManager` controls start/stop lifecycle.
|
||||
- **Two modes**: VPN (`CoreVpnService`, uses `VpnService.Builder`) or proxy-only (`CoreProxyOnlyService`, local SOCKS/HTTP).
|
||||
- **Flavors**: `fdroid` (suffix `.fdroid`) and `playstore` (no suffix). ABI version codes differ per flavor.
|
||||
- **hev-socks5-tunnel**: Optional tun2socks binary. Build with `./compile-hevtun.sh` (requires `NDK_HOME`).
|
||||
- **ViewBinding** enabled, no DataBinding.
|
||||
- **No CI**, no pre-commit hooks, no lint/format enforcement.
|
||||
Reference in new issue
Block a user