mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-30 06:56:25 +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 | ||
|
|
9fcb1a30f8 | ||
|
|
d4fc91502c | ||
|
|
75c9194ec0 | ||
|
|
590be2e9ae | ||
|
|
1ee1213563 | ||
|
|
37b42f04a7 | ||
|
|
3c91b51489 | ||
|
|
06a27ec4d4 | ||
|
|
7a07565f72 | ||
|
|
0456f835bc | ||
|
|
e4e3a36e93 | ||
|
|
4b3c8177db | ||
|
|
780d194213 | ||
|
|
c1374a73fb | ||
|
|
49fe45c49e | ||
|
|
36ab5615d9 | ||
|
|
91c2c93a62 | ||
|
|
10932f5764 | ||
|
|
8dcff4238f | ||
|
|
809bdcdb9d |
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.
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: b21389865e...c634d1baea.
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 744
|
||||
versionName = "2.3.4"
|
||||
versionCode = 747
|
||||
versionName = "2.3.7"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_start_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_start"
|
||||
android:shortcutDisabledMessage="@string/acc_start"
|
||||
android:shortcutId="shortcuts_start"
|
||||
android:shortcutLongLabel="@string/toast_services_start"
|
||||
android:shortcutShortLabel="@string/toast_services_start">
|
||||
android:shortcutLongLabel="@string/acc_start"
|
||||
android:shortcutShortLabel="@string/acc_start">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
@@ -48,10 +48,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_stop_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_stop"
|
||||
android:shortcutDisabledMessage="@string/acc_stop"
|
||||
android:shortcutId="shortcuts_stop"
|
||||
android:shortcutLongLabel="@string/toast_services_stop"
|
||||
android:shortcutShortLabel="@string/toast_services_stop">
|
||||
android:shortcutLongLabel="@string/acc_stop"
|
||||
android:shortcutShortLabel="@string/acc_stop">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
@@ -60,4 +60,4 @@
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
</shortcuts>
|
||||
@@ -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"
|
||||
|
||||
@@ -5,9 +5,9 @@ import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.compose.ThemeManager
|
||||
|
||||
@@ -35,7 +35,7 @@ class AngApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
MMKV.initialize(this)
|
||||
MmkvManager.initialize(this)
|
||||
|
||||
AppLocaleManager.initialize(this)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -17,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
|
||||
@@ -331,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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +362,7 @@ object CoreServiceManager {
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun startup(): Long {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback startup")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -379,14 +371,8 @@ object CoreServiceManager {
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun shutdown(): Long {
|
||||
val serviceControl = serviceControl?.get() ?: return -1
|
||||
return try {
|
||||
serviceControl.stopService()
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||
-1
|
||||
}
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback shutdown")
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,6 +382,7 @@ object CoreServiceManager {
|
||||
* @return Always returns 0.
|
||||
*/
|
||||
override fun onEmitStatus(l: Long, s: String?): Long {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback onEmitStatus $s")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -474,9 +461,20 @@ object CoreServiceManager {
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
LauncherManager.startService(serviceControl.getService())
|
||||
// The UI and daemon run in separate processes, so acknowledge the active
|
||||
// daemon before stopping it instead of relying on possibly stale UI state.
|
||||
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
try {
|
||||
serviceControl.stopService()
|
||||
delay(500L)
|
||||
LauncherManager.startService(serviceControl.getService())
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_DELAY -> {
|
||||
|
||||
@@ -56,6 +56,18 @@ object LauncherManager {
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
}
|
||||
|
||||
/** Restarts the active daemon without starting a stopped service. */
|
||||
fun restartService(context: Context) {
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_RESTART, "")
|
||||
}
|
||||
|
||||
/** Restarts the active daemon, or delegates to the caller's permission-aware start flow. */
|
||||
fun restartServiceOrStart(context: Context, startIfStopped: () -> Unit) {
|
||||
MessageHelper.sendMsg2ServiceForResult(context, AppConfig.MSG_STATE_RESTART, "") { handled ->
|
||||
if (!handled) startIfStopped()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun startContextService(context: Context) {
|
||||
// Note: isRunning check is removed here to avoid loading Native libraries in the UI process.
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.v2ray.ang.enums.EConfigType
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
const val THRESHOLD = 1000L
|
||||
const val DIVISOR = 1024.0
|
||||
@@ -76,3 +77,19 @@ fun EConfigType.isGroupType(): Boolean {
|
||||
fun EConfigType.isComplexType(): Boolean {
|
||||
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for delay with Int milliseconds using Duration to avoid legacy Long overload warning.
|
||||
*/
|
||||
suspend fun delay(millis: Int) {
|
||||
kotlinx.coroutines.delay(millis.toLong().milliseconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for delay with Long milliseconds using Duration to avoid legacy Long overload warning.
|
||||
*/
|
||||
suspend fun delay(millis: Long) {
|
||||
kotlinx.coroutines.delay(millis.milliseconds)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,46 @@ package com.v2ray.ang.fmt
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.V2rayNShareItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object V2rayNFmt : FmtBase() {
|
||||
fun parse(str: String): ProfileItem? {
|
||||
try {
|
||||
val jsonBase64Payload = str.substringAfterLast('/')
|
||||
val jsonPayload = Utils.decode(jsonBase64Payload)
|
||||
val v2rayNShareItem = JsonUtil.fromJson(jsonPayload, V2rayNShareItem::class.java)
|
||||
return v2rayNShareItem?.toProfileItem()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN format", e)
|
||||
fun parse(list: List<String>, subId: String): List<ProfileItem> {
|
||||
val idMap = mutableMapOf<String, V2rayNShareItem>()
|
||||
list.mapNotNull(::parseShareItem).forEach { item ->
|
||||
idMap.putIfAbsent(item.IndexId.orEmpty(), item)
|
||||
}
|
||||
return null
|
||||
|
||||
return idMap.values.map { item ->
|
||||
item.toProfileItem().apply {
|
||||
val proto = item.ProtoExtraObj
|
||||
policyGroupSubscriptionId = if (proto?.SubChildItems == "self") {
|
||||
subId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
proto?.ChildItems?.takeIf { it.isNotNullEmpty() }?.let { ids ->
|
||||
val remarks = ids.split(",")
|
||||
.mapNotNull { idMap[it]?.Remarks }
|
||||
.filter { it.isNotNullEmpty() }
|
||||
|
||||
if (remarks.isNotEmpty()) {
|
||||
when (item.ConfigType) {
|
||||
101 -> policyGroupFilter = remarks.joinToString("|", "^(", ")$") { Regex.escape(it) }
|
||||
102 -> proxyChainProfiles = remarks.joinToString(",")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseShareItem(str: String): V2rayNShareItem? = try {
|
||||
JsonUtil.fromJson(Utils.decode(str.substringAfterLast('/')), V2rayNShareItem::class.java)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN share item", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreConfigManager
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
@@ -31,6 +30,11 @@ import java.net.URI
|
||||
|
||||
object AngConfigManager {
|
||||
|
||||
private data class ParsedProfile(
|
||||
val profile: ProfileItem,
|
||||
val rawConfig: String? = null,
|
||||
)
|
||||
|
||||
// Parser mapping for different config types (lazy initialized)
|
||||
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
|
||||
mapOf(
|
||||
@@ -44,7 +48,6 @@ object AngConfigManager {
|
||||
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
|
||||
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
|
||||
AppConfig.HY2 to Hysteria2Fmt::parse,
|
||||
AppConfig.V2RAYNFMTS to V2rayNFmt::parse
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,23 +180,28 @@ object AngConfigManager {
|
||||
* @return A pair containing the number of configurations and subscriptions imported.
|
||||
*/
|
||||
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
return try {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
|
||||
return count to countSub
|
||||
count to countSub
|
||||
} catch (e: ProfileStorageException) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to store imported profiles", e)
|
||||
0 to 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,34 +244,40 @@ object AngConfigManager {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
// Find the currently selected server that belongs to the same subscription before replacement.
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
|
||||
// Parse all configs first (no I/O during parsing)
|
||||
val configs = mutableListOf<ProfileItem>()
|
||||
val v2raynLines = mutableListOf<String>()
|
||||
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.reversed()
|
||||
.forEach {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
if (it.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)) {
|
||||
v2raynLines.add(it)
|
||||
} else {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch save all parsed configs (only one serverList read/write)
|
||||
if (configs.isNotEmpty()) {
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val keyToProfile = batchSaveConfigs(configs, subid)
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
val v2raynConfigs = V2rayNFmt.parse(v2raynLines, subid)
|
||||
val allConfigs = v2raynConfigs + configs
|
||||
|
||||
if (allConfigs.isNotEmpty()) {
|
||||
commitProfiles(
|
||||
configs = allConfigs.map(::ParsedProfile),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
return configs.size
|
||||
return allConfigs.size
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
@@ -271,112 +285,32 @@ object AngConfigManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch save configurations to reduce serverList read/write operations.
|
||||
* Reads serverList once, saves all configs, then writes serverList once.
|
||||
* Commits parsed profiles before removing the profiles they replace.
|
||||
*
|
||||
* @param configs The list of ProfileItem to save.
|
||||
* @param configs The parsed profiles to save.
|
||||
* @param subid The subscription ID.
|
||||
* @return Map of generated keys to their corresponding ProfileItem.
|
||||
* @param append Whether to append to the existing server list.
|
||||
*/
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
private fun commitProfiles(
|
||||
configs: List<ParsedProfile>,
|
||||
subid: String,
|
||||
append: Boolean,
|
||||
) {
|
||||
val keyToProfile = linkedMapOf<String, ProfileItem>()
|
||||
val rawConfigs = mutableMapOf<String, String>()
|
||||
|
||||
// Read serverList once
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
|
||||
configs.forEach { config ->
|
||||
configs.forEach { parsed ->
|
||||
val key = Utils.getUuid()
|
||||
// Save profile directly without updating serverList
|
||||
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
}
|
||||
keyToProfile[key] = config
|
||||
keyToProfile[key] = parsed.profile
|
||||
parsed.rawConfig?.let { raw -> rawConfigs[key] = raw }
|
||||
}
|
||||
|
||||
// Write serverList once
|
||||
MmkvManager.encodeServerList(serverList, subid)
|
||||
return keyToProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a matched profile key from the given key-profile map using multi-level matching.
|
||||
* Matching priority (from highest to lowest):
|
||||
* 1. Exact match: server + port + password
|
||||
* 2. Match by remarks (exact match)
|
||||
* 3. Match by server + port
|
||||
* 4. Match by server only
|
||||
*
|
||||
* @param keyToProfile Map of server keys to their ProfileItem
|
||||
* @param target Target profile to match
|
||||
* @return Matched key or null
|
||||
*/
|
||||
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
|
||||
if (keyToProfile.isEmpty()) return null
|
||||
if (target == null) return null
|
||||
|
||||
// Level 0: Full match (remarks + server + port + password)
|
||||
if (target.remarks.isNotBlank()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks) &&
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 1: Match by remarks
|
||||
if (target.remarks.isNotBlank()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 2: Exact match (server + port + password)
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 3: Match by server + port
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 4: Match by server only
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// If old selected node cannot be matched, fall back to the first imported config.
|
||||
return keyToProfile.keys.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
|
||||
*/
|
||||
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
|
||||
if (subid.isBlank() || append) return null
|
||||
|
||||
return MmkvManager.getSelectServer()
|
||||
.takeIf { it?.isNotBlank() == true }
|
||||
?.let { MmkvManager.decodeServerConfig(it) }
|
||||
?.takeIf { it.subscriptionId == subid }
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive trimmed string comparison.
|
||||
*
|
||||
* @param left First string
|
||||
* @param right Second string
|
||||
* @return True if both are non-empty and equal (case-insensitive, trimmed)
|
||||
*/
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
MmkvManager.saveServerProfiles(
|
||||
profiles = keyToProfile,
|
||||
rawConfigs = rawConfigs,
|
||||
subscriptionId = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,56 +334,54 @@ object AngConfigManager {
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
var count = 0
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
val configs = serverList.reversed().map { srv ->
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv))
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
keyToProfile[key] = config
|
||||
count += 1
|
||||
ParsedProfile(
|
||||
profile = config,
|
||||
rawConfig = JsonUtil.toJsonPretty(srv) ?: "",
|
||||
)
|
||||
}
|
||||
if (count > 0) {
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
return count
|
||||
commitProfiles(configs, subid, append)
|
||||
return configs.size
|
||||
}
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// For compatibility
|
||||
val config = CustomFmt.parse(server) ?: return 0
|
||||
val config = CustomFmt.parse(server)
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
commitProfiles(
|
||||
configs = listOf(ParsedProfile(config, server)),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
return 1
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
}
|
||||
return 0
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
try {
|
||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||
val config = WireguardFmt.parseWireguardConfFile(server)
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
commitProfiles(
|
||||
configs = listOf(ParsedProfile(config, server)),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
return 1
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
@@ -496,12 +428,6 @@ object AngConfigManager {
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
|
||||
if (str.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)
|
||||
&& config.policyGroupSubscriptionId == "self"
|
||||
) {
|
||||
config.policyGroupSubscriptionId = subid
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -7,9 +9,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.tencent.mmkv.MMKVHandler
|
||||
import com.tencent.mmkv.MMKVLogLevel
|
||||
import com.tencent.mmkv.MMKVRecoverStrategic
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||
import com.v2ray.ang.AppConfig.TAG
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
@@ -24,6 +31,8 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
|
||||
internal class ProfileStorageException(message: String) : IllegalStateException(message)
|
||||
|
||||
object MmkvManager {
|
||||
|
||||
//region private
|
||||
@@ -41,6 +50,24 @@ object MmkvManager {
|
||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||
|
||||
private val recoveryHandler = object : MMKVHandler {
|
||||
override fun onMMKVCRCCheckFail(mmapID: String) =
|
||||
recoverFromStorageError(mmapID, "CRC check")
|
||||
|
||||
override fun onMMKVFileLengthError(mmapID: String) =
|
||||
recoverFromStorageError(mmapID, "file length check")
|
||||
|
||||
override fun wantLogRedirecting(): Boolean = false
|
||||
|
||||
override fun mmkvLog(
|
||||
level: MMKVLogLevel,
|
||||
file: String,
|
||||
line: Int,
|
||||
function: String,
|
||||
message: String
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||
@@ -49,8 +76,83 @@ object MmkvManager {
|
||||
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
|
||||
|
||||
private inline fun <T> withProfileIndexLock(block: () -> T): T {
|
||||
return synchronized(mainStorage) {
|
||||
mainStorage.lock()
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
mainStorage.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeProfilePayloads(guids: Collection<String>) {
|
||||
if (guids.isEmpty()) return
|
||||
val keys = guids.toTypedArray()
|
||||
profileFullStorage.removeValuesForKeys(keys)
|
||||
serverAffStorage.removeValuesForKeys(keys)
|
||||
serverRawStorage.removeValuesForKeys(keys)
|
||||
}
|
||||
|
||||
private fun requireStorageWrite(success: Boolean, message: String) {
|
||||
if (!success) throw ProfileStorageException(message)
|
||||
}
|
||||
|
||||
private fun persistServerList(serverList: List<String>, subscriptionId: String): Boolean {
|
||||
return mainStorage.encode(serverListKey(subscriptionId), JsonUtil.toJson(serverList))
|
||||
}
|
||||
|
||||
private fun serverListKey(subscriptionId: String): String {
|
||||
return "$KEY_SUB_SERVER_PREFIX${getSubscriptionId(subscriptionId)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every server referenced outside the target group, or null if the raw indexes
|
||||
* cannot provide a complete view.
|
||||
*/
|
||||
private fun decodeServersReferencedByOtherGroups(subscriptionId: String): Set<String>? {
|
||||
val targetKey = serverListKey(subscriptionId)
|
||||
val keys = mainStorage.allKeys() ?: return null
|
||||
if (targetKey !in keys) return null
|
||||
|
||||
val referencedServers = mutableSetOf<String>()
|
||||
for (key in keys) {
|
||||
if (!key.startsWith(KEY_SUB_SERVER_PREFIX) || key == targetKey) continue
|
||||
|
||||
val json = mainStorage.decodeString(key)
|
||||
if (json.isNullOrBlank()) return null
|
||||
val serverIds = JsonUtil.fromJsonSafe(json, Array<String>::class.java) ?: return null
|
||||
referencedServers.addAll(serverIds)
|
||||
}
|
||||
return referencedServers
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
/**
|
||||
* Initializes MMKV with best-effort recovery so a damaged store is not silently discarded.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
val logLevel = if (BuildConfig.DEBUG) {
|
||||
MMKVLogLevel.LevelDebug
|
||||
} else {
|
||||
MMKVLogLevel.LevelInfo
|
||||
}
|
||||
MMKV.initialize(
|
||||
context,
|
||||
context.filesDir.resolve("mmkv").absolutePath,
|
||||
null,
|
||||
logLevel,
|
||||
recoveryHandler
|
||||
)
|
||||
}
|
||||
|
||||
private fun recoverFromStorageError(mmapID: String, error: String): MMKVRecoverStrategic {
|
||||
Log.e(TAG, "MMKV $error failed for $mmapID; attempting data recovery")
|
||||
return MMKVRecoverStrategic.OnErrorRecover
|
||||
}
|
||||
|
||||
//region Server
|
||||
|
||||
/**
|
||||
@@ -79,7 +181,9 @@ object MmkvManager {
|
||||
* @param guid The server GUID.
|
||||
*/
|
||||
fun setSelectServer(guid: String) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
withProfileIndexLock {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,9 +194,9 @@ object MmkvManager {
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
||||
withProfileIndexLock {
|
||||
persistServerList(serverList, subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,9 +209,7 @@ object MmkvManager {
|
||||
* @return The list of server GUIDs.
|
||||
*/
|
||||
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
val json = mainStorage.decodeString(key)
|
||||
val json = mainStorage.decodeString(serverListKey(subscriptionId))
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
@@ -166,17 +268,28 @@ object MmkvManager {
|
||||
*/
|
||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||
val key = guid.ifBlank { Utils.getUuid() }
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||
withProfileIndexLock {
|
||||
requireStorageWrite(
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config)),
|
||||
"Failed to save profile payload",
|
||||
)
|
||||
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
encodeServerList(serverList, subId)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
requireStorageWrite(
|
||||
persistServerList(serverList, subId),
|
||||
"Failed to publish profile index",
|
||||
)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
requireStorageWrite(
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key),
|
||||
"Failed to update selected profile",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,13 +297,88 @@ object MmkvManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the server configuration directly without updating serverList.
|
||||
* Saves a profile batch before publishing its group index and removing replaced payloads.
|
||||
*
|
||||
* @param key The server GUID.
|
||||
* @param configJson The server configuration JSON string.
|
||||
* @param profiles Generated GUIDs and parsed profiles, in insertion order.
|
||||
* @param rawConfigs Optional raw configuration payloads keyed by profile GUID.
|
||||
* @param subscriptionId The destination subscription ID.
|
||||
* @param append Whether to append to the existing group index.
|
||||
*/
|
||||
fun encodeProfileDirect(key: String, configJson: String) {
|
||||
profileFullStorage.encode(key, configJson)
|
||||
internal fun saveServerProfiles(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
rawConfigs: Map<String, String>,
|
||||
subscriptionId: String,
|
||||
append: Boolean,
|
||||
) {
|
||||
if (profiles.isEmpty()) return
|
||||
|
||||
withProfileIndexLock {
|
||||
val replacedServers = if (append) {
|
||||
emptyList()
|
||||
} else {
|
||||
decodeServerList(subscriptionId).toList()
|
||||
}
|
||||
val previousSelection = getSelectServer()
|
||||
val selectedProfile = if (!append &&
|
||||
previousSelection != null &&
|
||||
previousSelection in replacedServers
|
||||
) {
|
||||
decodeServerConfig(previousSelection)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val replacementSelection = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = previousSelection,
|
||||
selectedProfile = selectedProfile,
|
||||
)
|
||||
|
||||
profiles.forEach { (guid, profile) ->
|
||||
requireStorageWrite(
|
||||
profileFullStorage.encode(guid, JsonUtil.toJson(profile)),
|
||||
"Failed to save profile payload",
|
||||
)
|
||||
rawConfigs[guid]?.let { raw ->
|
||||
requireStorageWrite(
|
||||
serverRawStorage.encode(guid, raw),
|
||||
"Failed to save raw profile payload",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val serverList = if (append) {
|
||||
decodeServerList(subscriptionId)
|
||||
} else {
|
||||
mutableListOf()
|
||||
}
|
||||
val indexedServers = serverList.toHashSet()
|
||||
profiles.keys.forEach { guid ->
|
||||
if (indexedServers.add(guid)) {
|
||||
serverList.add(0, guid)
|
||||
}
|
||||
}
|
||||
requireStorageWrite(
|
||||
persistServerList(serverList, subscriptionId),
|
||||
"Failed to publish profile index",
|
||||
)
|
||||
replacementSelection?.let { guid ->
|
||||
requireStorageWrite(
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid),
|
||||
"Failed to update selected profile",
|
||||
)
|
||||
}
|
||||
if (replacedServers.isEmpty()) return@withProfileIndexLock
|
||||
|
||||
val protectedServer = replacementSelection ?: previousSelection
|
||||
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
|
||||
val removablePayloads = ProfileReplacement.findRemovablePayloads(
|
||||
replacedServers = replacedServers,
|
||||
replacementServers = profiles.keys,
|
||||
protectedServer = protectedServer,
|
||||
serversReferencedByOtherGroups = referencedByOtherGroups,
|
||||
)
|
||||
removeProfilePayloads(removablePayloads)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,6 +566,56 @@ object MmkvManager {
|
||||
return serverRawStorage.decodeString(guid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes profile payloads that are provably absent from their raw SUB_SERVERS_* index.
|
||||
*
|
||||
* SUB_IDS and SUB are intentionally ignored: either store can be missing after MMKV
|
||||
* recovery while the group indexes still identify live profiles. If any group index or
|
||||
* profile payload needed for a decision is unreadable, that data is preserved.
|
||||
*
|
||||
* @return The number of profile payloads removed, or null if cleanup could not run safely.
|
||||
*/
|
||||
internal fun removeOrphanedServerProfiles(): Int? = synchronized(mainStorage) {
|
||||
mainStorage.lock()
|
||||
try {
|
||||
val indexedServersBySubscription = mainStorage.allKeys().orEmpty()
|
||||
.asSequence()
|
||||
.filter { key -> key.startsWith(KEY_SUB_SERVER_PREFIX) }
|
||||
.associate { key ->
|
||||
val subscriptionId = key.removePrefix(KEY_SUB_SERVER_PREFIX)
|
||||
val json = mainStorage.decodeString(key)
|
||||
val serverIds = if (json.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toSet()
|
||||
}
|
||||
subscriptionId to serverIds
|
||||
}
|
||||
|
||||
val profiles = profileFullStorage.allKeys().orEmpty().map { guid ->
|
||||
StoredProfileReference(
|
||||
guid = guid,
|
||||
subscriptionId = decodeServerConfig(guid)?.subscriptionId,
|
||||
)
|
||||
}
|
||||
val orphans = OrphanProfileCleaner.findOrphans(
|
||||
profiles = profiles,
|
||||
indexedServersBySubscription = indexedServersBySubscription,
|
||||
selectedServer = getSelectServer(),
|
||||
) ?: return@synchronized null
|
||||
|
||||
if (orphans.isNotEmpty()) {
|
||||
val keys = orphans.toTypedArray()
|
||||
profileFullStorage.removeValuesForKeys(keys)
|
||||
serverAffStorage.removeValuesForKeys(keys)
|
||||
serverRawStorage.removeValuesForKeys(keys)
|
||||
}
|
||||
orphans.size
|
||||
} finally {
|
||||
mainStorage.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Subscriptions
|
||||
@@ -480,7 +718,8 @@ object MmkvManager {
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
|
||||
// Keep the first occurrence so a damaged index cannot produce duplicate Compose keys.
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.distinct()?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.toSpeedString
|
||||
import com.v2ray.ang.ui.main.MainActivity
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
|
||||
internal data class StoredProfileReference(
|
||||
val guid: String,
|
||||
val subscriptionId: String?,
|
||||
)
|
||||
|
||||
internal object OrphanProfileCleaner {
|
||||
|
||||
/**
|
||||
* Finds profile payloads that are provably unreachable from the raw group indexes.
|
||||
*
|
||||
* A null subscription ID means that the profile payload could not be decoded and is
|
||||
* preserved. A null server set means that a group index could not be decoded, in which
|
||||
* case the entire classification returns null and no cleanup should run.
|
||||
* Subscription metadata is deliberately not an input because SUB and SUB_IDS can be
|
||||
* missing while the raw SUB_SERVERS_* indexes remain intact.
|
||||
*/
|
||||
fun findOrphans(
|
||||
profiles: Collection<StoredProfileReference>,
|
||||
indexedServersBySubscription: Map<String, Set<String>?>,
|
||||
selectedServer: String?,
|
||||
): Set<String>? {
|
||||
if (profiles.isEmpty()) return emptySet()
|
||||
|
||||
if (indexedServersBySubscription.isEmpty() ||
|
||||
indexedServersBySubscription.values.any { it == null }
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val indexedServers = indexedServersBySubscription.values
|
||||
.filterNotNull()
|
||||
.flatten()
|
||||
.toSet()
|
||||
|
||||
return profiles.mapNotNullTo(linkedSetOf()) { profile ->
|
||||
if (profile.guid == selectedServer || profile.guid in indexedServers) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
val subscriptionId = profile.subscriptionId ?: return@mapNotNullTo null
|
||||
val groupId = subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||
if (!indexedServersBySubscription.containsKey(groupId)) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
profile.guid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
internal object ProfileReplacement {
|
||||
|
||||
/**
|
||||
* Finds the profile that should become selected after publishing a replacement batch.
|
||||
* The first profile becomes selected when the store has no current selection.
|
||||
*/
|
||||
fun findSelectedReplacement(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
currentSelection: String?,
|
||||
selectedProfile: ProfileItem?,
|
||||
): String? {
|
||||
if (profiles.isEmpty()) return null
|
||||
if (currentSelection.isNullOrBlank()) return profiles.keys.first()
|
||||
if (selectedProfile == null) return null
|
||||
|
||||
if (selectedProfile.remarks.isNotBlank()) {
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks) &&
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
return profiles.keys.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds replaced payloads that are safe to remove.
|
||||
*
|
||||
* A null cross-group reference set means that at least one raw group index could not
|
||||
* be read. In that case deletion fails closed.
|
||||
*/
|
||||
fun findRemovablePayloads(
|
||||
replacedServers: Collection<String>,
|
||||
replacementServers: Set<String>,
|
||||
protectedServer: String?,
|
||||
serversReferencedByOtherGroups: Set<String>?,
|
||||
): Set<String> {
|
||||
if (serversReferencedByOtherGroups == null) return emptySet()
|
||||
|
||||
return replacedServers.filterTo(linkedSetOf()) { guid ->
|
||||
guid != protectedServer &&
|
||||
guid !in replacementServers &&
|
||||
guid !in serversReferencedByOtherGroups
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -27,6 +29,37 @@ object MessageHelper {
|
||||
sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an ordered service message and reports whether a daemon receiver handled it.
|
||||
* With no running daemon, the initial canceled result reaches [onResult] unchanged.
|
||||
*/
|
||||
internal fun sendMsg2ServiceForResult(
|
||||
ctx: Context,
|
||||
what: Int,
|
||||
content: Serializable,
|
||||
onResult: (handled: Boolean) -> Unit,
|
||||
) {
|
||||
val resultReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
onResult(resultCode == Activity.RESULT_OK)
|
||||
}
|
||||
}
|
||||
try {
|
||||
ctx.sendOrderedBroadcast(
|
||||
messageIntent(AppConfig.BROADCAST_ACTION_SERVICE, what, content),
|
||||
null,
|
||||
resultReceiver,
|
||||
null,
|
||||
Activity.RESULT_CANCELED,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send ordered message to service", e)
|
||||
onResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the UI.
|
||||
*
|
||||
@@ -115,14 +148,16 @@ object MessageHelper {
|
||||
*/
|
||||
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
|
||||
try {
|
||||
val intent = Intent()
|
||||
intent.action = action
|
||||
intent.`package` = AppConfig.ANG_PACKAGE
|
||||
intent.putExtra("key", what)
|
||||
intent.putExtra("content", content)
|
||||
ctx.sendBroadcast(intent)
|
||||
ctx.sendBroadcast(messageIntent(action, what, content))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageIntent(action: String, what: Int, content: Serializable): Intent =
|
||||
Intent(action).apply {
|
||||
`package` = AppConfig.ANG_PACKAGE
|
||||
putExtra("key", what)
|
||||
putExtra("content", content)
|
||||
}
|
||||
}
|
||||
@@ -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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -5,12 +5,12 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,9 +18,26 @@ import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
internal object RealPingExecutionLimiter {
|
||||
private val customConfigMutex = Mutex()
|
||||
|
||||
suspend fun <T> run(configType: EConfigType, block: () -> T): T {
|
||||
// Custom profiles bypass speed-test trimming and start complete Xray configs.
|
||||
// Parallel teardown can abort the native probe process, so serialize their
|
||||
// JNI measurements globally across batches.
|
||||
return if (configType == EConfigType.CUSTOM) {
|
||||
customConfigMutex.withLock { block() }
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker that runs a batch of real-ping tests independently.
|
||||
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
|
||||
@@ -87,7 +104,7 @@ class RealPingWorkerService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRealPing(guid: String): Long {
|
||||
private suspend fun startRealPing(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
|
||||
@@ -110,7 +127,9 @@ class RealPingWorkerService(
|
||||
if (!configResult.status) {
|
||||
return retFailure
|
||||
}
|
||||
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
return RealPingExecutionLimiter.run(config.configType) {
|
||||
CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTcping(guid: String): Long {
|
||||
@@ -120,7 +139,7 @@ class RealPingWorkerService(
|
||||
if (!config.configType.isComplexType()
|
||||
&& config.configType != EConfigType.HYSTERIA2
|
||||
&& config.configType != EConfigType.WIREGUARD
|
||||
&& config.alpn?.startsWith("h3") != true
|
||||
&& config.alpn?.split(',')?.all { it.trim().startsWith("h3") } != true
|
||||
&& config.server.isNotNullEmpty()
|
||||
&& config.serverPort?.toIntOrNull() != null
|
||||
) {
|
||||
|
||||
@@ -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,8 +1,10 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -12,7 +14,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -32,6 +33,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.VersionInfoBlock
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -44,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) }
|
||||
|
||||
@@ -58,7 +68,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
val appIdText = BuildConfig.APPLICATION_ID
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_about),
|
||||
@@ -82,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),
|
||||
@@ -101,6 +116,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
versionText = versionText,
|
||||
appIdText = appIdText
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -16,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.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -35,6 +35,7 @@ import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppListItem
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
|
||||
private enum class AppPickerMenuAction(@StringRes val labelRes: Int) {
|
||||
@@ -127,7 +128,7 @@ fun AppPickerScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = title,
|
||||
@@ -184,7 +185,8 @@ fun AppPickerScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = selectedPackages.contains(app.packageName)
|
||||
|
||||
@@ -7,13 +7,13 @@ import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -38,8 +38,10 @@ import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.InputDialog
|
||||
import com.v2ray.ang.ui.compose.InputField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -105,6 +107,7 @@ class BackupActivity : HelperBaseComponentActivity() {
|
||||
BackupLocation.WebDav -> viewModel.restoreViaWebDav(cacheDir)
|
||||
}
|
||||
},
|
||||
onCleanupProfiles = viewModel::cleanupProfileStorage,
|
||||
onWebDavSave = { config -> viewModel.saveWebDavConfig(config) },
|
||||
onBackClick = { finish() }
|
||||
)
|
||||
@@ -186,6 +189,7 @@ fun BackupScreen(
|
||||
onBackupOptionSelected: (BackupLocation) -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
onRestoreOptionSelected: (BackupLocation) -> Unit,
|
||||
onCleanupProfiles: () -> Unit,
|
||||
onWebDavSave: (WebDavConfig) -> Unit,
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
@@ -193,12 +197,13 @@ fun BackupScreen(
|
||||
val currentWebDavConfig by webDavConfigState.collectAsState()
|
||||
var showBackupDialog by remember { mutableStateOf(false) }
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var showCleanupDialog by remember { mutableStateOf(false) }
|
||||
var showWebDavDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val webDavSummary = currentWebDavConfig?.baseUrl
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_configuration_backup_restore),
|
||||
@@ -228,6 +233,12 @@ fun BackupScreen(
|
||||
title = stringResource(R.string.title_configuration_restore),
|
||||
onClick = { showRestoreDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_delete_24dp),
|
||||
title = stringResource(R.string.title_profile_storage_cleanup),
|
||||
subtitle = stringResource(R.string.summary_profile_storage_cleanup),
|
||||
onClick = { showCleanupDialog = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_settings_24dp),
|
||||
@@ -235,6 +246,7 @@ fun BackupScreen(
|
||||
subtitle = webDavSummary,
|
||||
onClick = { showWebDavDialog = true }
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +274,13 @@ fun BackupScreen(
|
||||
onDismiss = { showRestoreDialog = false }
|
||||
)
|
||||
}
|
||||
if (showCleanupDialog) {
|
||||
DeleteConfirmDialog(
|
||||
message = stringResource(R.string.message_profile_storage_cleanup),
|
||||
onConfirm = onCleanupProfiles,
|
||||
onDismiss = { showCleanupDialog = false }
|
||||
)
|
||||
}
|
||||
if (showWebDavDialog) {
|
||||
WebDavInputDialog(
|
||||
initialConfig = currentWebDavConfig,
|
||||
|
||||
@@ -13,9 +13,11 @@ import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.ui.base.ViewModelEvent
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.ZipUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@@ -37,6 +39,24 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
|
||||
fun cleanupProfileStorage() {
|
||||
launchLoading {
|
||||
try {
|
||||
val removed = withContext(Dispatchers.IO) {
|
||||
MmkvManager.removeOrphanedServerProfiles()
|
||||
}
|
||||
if (removed == null) {
|
||||
toastError(R.string.toast_profile_storage_cleanup_skipped)
|
||||
} else {
|
||||
toastSuccess(getString(R.string.toast_profile_storage_cleanup, removed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to clean up profile storage", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareBackup(cacheDir: File, appName: String) {
|
||||
launchLoading {
|
||||
val ret = backupConfigurationToCache(cacheDir, appName)
|
||||
@@ -165,17 +185,20 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun performRestore(cacheDir: File, zipFile: File): Boolean {
|
||||
val backupDir = cacheDir.absolutePath + "/${System.currentTimeMillis()}"
|
||||
private suspend fun performRestore(cacheDir: File, zipFile: File): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val backupDir = File(cacheDir, "restore_${System.nanoTime()}")
|
||||
try {
|
||||
if (!ZipUtil.unzipToFolder(zipFile, backupDir.absolutePath)) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
|
||||
return false
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir.absolutePath)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
count > 0
|
||||
} finally {
|
||||
backupDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.v2ray.ang.ui.checkupdate
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -11,7 +12,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,6 +26,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.VersionInfoBlock
|
||||
@@ -65,7 +66,7 @@ fun CheckUpdateScreen(
|
||||
val versionText = "v${BuildConfig.VERSION_NAME} ($libVersion)"
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.update_check_for_update),
|
||||
@@ -92,6 +93,7 @@ fun CheckUpdateScreen(
|
||||
onClick = { viewModel.checkForUpdates() }
|
||||
)
|
||||
VersionInfoBlock(versionText = versionText)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,19 @@ 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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.Checkbox
|
||||
@@ -225,6 +230,17 @@ fun AppDivider(modifier: Modifier = Modifier) {
|
||||
HorizontalDivider(modifier = modifier.fillMaxWidth(), thickness = 1.dp, color = color)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationBarsSpacer(modifier: Modifier = Modifier) {
|
||||
Spacer(modifier = modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationBarsBottomPadding(): PaddingValues {
|
||||
val bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
return PaddingValues(bottom = bottom)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionInfoBlock(
|
||||
versionText: String,
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.currentStateAsState
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
data class ScrollbarConfig(
|
||||
|
||||
@@ -31,13 +31,12 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
enum class ToastType {
|
||||
NORMAL, SUCCESS, ERROR, INFO
|
||||
@@ -88,7 +87,7 @@ class AppSnackbarController(
|
||||
if (currentShowTime != 0L) {
|
||||
val elapsed = System.currentTimeMillis() - currentShowTime
|
||||
if (elapsed < SnackbarThrottleMs) {
|
||||
delay((SnackbarThrottleMs - elapsed).milliseconds)
|
||||
delay((SnackbarThrottleMs - elapsed))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -21,7 +23,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -43,6 +44,7 @@ import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -130,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("") }
|
||||
@@ -139,7 +141,7 @@ fun LogcatScreen(
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_logcat),
|
||||
@@ -190,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)
|
||||
@@ -209,10 +212,11 @@ fun LogcatScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(items = logs, key = { index, _ -> index }) { _, log ->
|
||||
LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) })
|
||||
items(items = logs, key = { it.key }) { log ->
|
||||
LogcatItem(log = log.text, onLongClick = { Utils.setClipboard(context, log.text) })
|
||||
ItemDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package com.v2ray.ang.ui.logcat
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.io.IOException
|
||||
|
||||
class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
@@ -17,6 +23,10 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
private val _filteredLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
|
||||
val logEntries: StateFlow<List<LogcatEntry>> = filteredLogs
|
||||
.map(::createLogcatEntries)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun loadLogcat() {
|
||||
launchLoading {
|
||||
@@ -73,4 +83,17 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
logsetsAll.filter { it.contains(currentFilter) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LogcatEntry(val key: String, val text: String)
|
||||
|
||||
internal fun createLogcatEntries(logs: List<String>): List<LogcatEntry> {
|
||||
val occurrences = HashMap<String, Int>()
|
||||
// Identical lines are valid. Count oldest-first so filtering other lines or
|
||||
// prepending newer entries does not change the keys of existing occurrences.
|
||||
return logs.asReversed().map { line ->
|
||||
val occurrence = occurrences.getOrDefault(line, 0)
|
||||
occurrences[line] = occurrence + 1
|
||||
LogcatEntry("$occurrence:$line", line)
|
||||
}.asReversed()
|
||||
}
|
||||
@@ -75,10 +75,10 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val restartService = data.getBooleanExtra(
|
||||
ProfileEditorResult.EXTRA_RESTART_SERVICE, false
|
||||
)
|
||||
val selectedProfileSaved = action == ProfileEditorResult.ACTION_SAVED &&
|
||||
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
|
||||
mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService && mainViewModel.uiState.value.isRunning) {
|
||||
restartV2Ray()
|
||||
}
|
||||
if (restartService || selectedProfileSaved) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
private val settingsActivityLauncher =
|
||||
@@ -87,7 +87,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val refreshGroups = SettingsChangeManager.consumeSetupGroupTab()
|
||||
mainViewModel.refreshUiSettings()
|
||||
if (refreshGroups) mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService && mainViewModel.uiState.value.isRunning) restartV2Ray()
|
||||
if (restartService) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -110,7 +110,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
MainAction.ImportClipboard -> importClipboard()
|
||||
MainAction.ImportConfigLocal -> importConfigLocal()
|
||||
is MainAction.ImportManually -> importManually(action.type)
|
||||
MainAction.RestartService -> restartV2Ray()
|
||||
MainAction.RestartService -> LauncherManager.restartServiceOrStart(this, ::requestServiceStart)
|
||||
MainAction.LocateSelectedServer -> mainViewModel.triggerLocateSelectedServer()
|
||||
is MainAction.SelectServer -> setSelectServer(action.guid)
|
||||
is MainAction.EditServer -> editServer(action.guid, action.profile)
|
||||
@@ -161,14 +161,20 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
private fun handleFabAction() {
|
||||
if (mainViewModel.uiState.value.isRunning) {
|
||||
LauncherManager.stopService(this)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
|
||||
} else {
|
||||
startV2Ray()
|
||||
requestServiceStart()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestServiceStart() {
|
||||
if (!SettingsManager.isVpnMode()) {
|
||||
startV2Ray()
|
||||
return
|
||||
}
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
|
||||
}
|
||||
|
||||
private fun handleLayoutTestClick() {
|
||||
if (mainViewModel.uiState.value.isRunning) {
|
||||
mainViewModel.testCurrentServerRealPing()
|
||||
@@ -188,14 +194,6 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
LauncherManager.startService(this)
|
||||
}
|
||||
|
||||
private fun restartV2Ray() {
|
||||
if (mainViewModel.uiState.value.isRunning) LauncherManager.stopService(this)
|
||||
lifecycleScope.launch {
|
||||
kotlinx.coroutines.delay(500)
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun importManually(createConfigType: Int) {
|
||||
val intent = when (createConfigType) {
|
||||
EConfigType.POLICYGROUP.value -> Intent(this, ServerGroupActivity::class.java)
|
||||
@@ -275,7 +273,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val selected = mainViewModel.uiState.value.selectedGuid
|
||||
if (guid != selected) {
|
||||
mainViewModel.updateSelectedGuid(guid)
|
||||
if (mainViewModel.uiState.value.isRunning) restartV2Ray()
|
||||
LauncherManager.restartService(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -17,7 +18,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -25,6 +25,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.ui.compose.AppDivider
|
||||
@@ -40,26 +42,29 @@ fun MainBottomBar(
|
||||
onAction: (MainAction) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable(onClick = { onAction(MainAction.TestCurrentServer) })
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
) {
|
||||
AppDivider()
|
||||
Surface(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.height(64.dp)
|
||||
.clickable(onClick = { onAction(MainAction.TestCurrentServer) }),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 0.dp
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = displayText, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = displayText
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
|
||||
@@ -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 kotlinx.coroutines.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(16L)
|
||||
}
|
||||
|
||||
if (latestDoubleColumnDisplay) {
|
||||
lazyGridStates[target.groupId]?.let { gridState ->
|
||||
gridState.scrollToItem(
|
||||
index = target.itemPosition,
|
||||
scrollOffset = -gridState.layoutInfo.viewportSize.height / 3
|
||||
)
|
||||
}
|
||||
} else {
|
||||
lazyListStates[target.groupId]?.let { listState ->
|
||||
listState.scrollToItem(
|
||||
index = target.itemPosition,
|
||||
scrollOffset = -listState.layoutInfo.viewportSize.height / 3
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
delay(32L)
|
||||
locateInProgress = false
|
||||
mainViewModel.onAction(MainAction.LocateHandled(target))
|
||||
}
|
||||
}
|
||||
|
||||
MainDialogs(
|
||||
showDelAllConfirm = showDelAllConfirm,
|
||||
onDismissDelAll = { showDelAllConfirm = false },
|
||||
@@ -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,6 +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.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
|
||||
@@ -44,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
|
||||
@@ -67,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>,
|
||||
@@ -79,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
|
||||
) {
|
||||
@@ -133,6 +149,8 @@ private fun ServerListPage(
|
||||
}
|
||||
} else null
|
||||
|
||||
LocateTargetEffect(locateTarget, rows, gridState, onLocateHandled)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
state = gridState,
|
||||
@@ -141,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,
|
||||
@@ -180,6 +193,8 @@ private fun ServerListPage(
|
||||
}
|
||||
} else null
|
||||
|
||||
LocateTargetEffect(locateTarget, rows, listState, onLocateHandled)
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
@@ -187,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()
|
||||
}
|
||||
@@ -229,103 +234,96 @@ private fun ServerListPage(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerItemRow(
|
||||
serverCache: ServersCache,
|
||||
selectedGuid: String?,
|
||||
subscriptionId: String,
|
||||
onSelectServer: (String) -> Unit,
|
||||
onEditServer: (String, ProfileItem) -> Unit,
|
||||
onShareServer: (String, ProfileItem) -> Unit,
|
||||
onMoreServer: (String, ProfileItem) -> Unit,
|
||||
onRemoveServer: (String) -> Unit
|
||||
private fun LocateTargetEffect(
|
||||
target: LocateTarget?,
|
||||
rows: List<ServerRowUiModel>,
|
||||
state: LazyListState,
|
||||
onHandled: () -> Unit,
|
||||
) {
|
||||
val profile = serverCache.profile
|
||||
val subRemarks = if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
||||
?.toString() ?: ""
|
||||
} else ""
|
||||
if (target == null) return
|
||||
LaunchedEffect(target, rows) {
|
||||
val index = rows.indexOfFirst { it.guid == target.serverGuid }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
|
||||
onHandled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocateTargetEffect(
|
||||
target: LocateTarget?,
|
||||
rows: List<ServerRowUiModel>,
|
||||
state: LazyGridState,
|
||||
onHandled: () -> Unit,
|
||||
) {
|
||||
if (target == null) return
|
||||
LaunchedEffect(target, rows) {
|
||||
val index = rows.indexOfFirst { it.guid == target.serverGuid }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
|
||||
onHandled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerItemRow(
|
||||
row: ServerRowUiModel,
|
||||
isSelected: Boolean,
|
||||
actions: ServerRowActions
|
||||
) {
|
||||
ServerListItem(
|
||||
remarks = profile.remarks,
|
||||
statistics = profile.description.nullIfBlank()
|
||||
?: AngConfigManager.generateDescription(profile),
|
||||
typeDescription = getProtocolDescription(profile),
|
||||
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)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.clickable(onClick = onClick)
|
||||
.then(dragModifier)
|
||||
.semantics {
|
||||
if (selectedStateDescription != null) {
|
||||
stateDescription = selectedStateDescription
|
||||
}
|
||||
}
|
||||
.clickable { actions.select(row.guid) }
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -352,58 +350,69 @@ fun ServerListItem(
|
||||
.padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(row.remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (doubleColumnDisplay) {
|
||||
IconButton(onClick = onMore, Modifier.size(36.dp)) {
|
||||
Icon(painterResource(R.drawable.ic_more_vert_24dp), null, Modifier.size(24.dp))
|
||||
IconButton(onClick = { actions.more(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
stringResource(R.string.acc_more),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onShare, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_share_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = onEdit, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_edit_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = onRemove, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_delete_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = { actions.share(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_share_24dp),
|
||||
stringResource(R.string.title_configuration_share),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { actions.edit(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_edit_24dp),
|
||||
stringResource(R.string.acc_edit),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { actions.remove(row.guid) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_delete_24dp),
|
||||
stringResource(R.string.acc_delete),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (subscriptionRemarks.isNotBlank()) {
|
||||
if (row.subscriptionBadge.isNotBlank()) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), Alignment.Center
|
||||
) {
|
||||
Text(subscriptionRemarks.take(1).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
Text(row.subscriptionBadge.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Text(statistics, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
row.statistics,
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(row.typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (row.testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getProtocolDescription(profile: ProfileItem): String {
|
||||
if (profile.configType.isComplexType()) return profile.configType.name
|
||||
val parts = mutableListOf(profile.configType.name)
|
||||
profile.network?.let { net ->
|
||||
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) parts.add(net)
|
||||
}
|
||||
profile.security?.let { sec ->
|
||||
if (sec.isNotBlank()) {
|
||||
if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) {
|
||||
parts.add("$sec insecure")
|
||||
} else {
|
||||
parts.add(sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.joinToString(" / ")
|
||||
}
|
||||
|
||||
internal suspend fun PagerState.navigateToPageOptimized(
|
||||
targetPage: Int,
|
||||
animateAdjacentPage: Boolean = true
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
|
||||
internal data class ServerRowUiModel(
|
||||
val guid: String,
|
||||
val profile: ProfileItem,
|
||||
val remarks: String,
|
||||
val statistics: String,
|
||||
val typeDescription: String,
|
||||
val testDelayMillis: Long,
|
||||
val subscriptionBadge: String,
|
||||
)
|
||||
|
||||
internal data class ServerGroupUiState(
|
||||
val servers: List<ServersCache> = emptyList(),
|
||||
val rows: List<ServerRowUiModel> = emptyList(),
|
||||
)
|
||||
|
||||
internal fun buildServerRowUiModel(
|
||||
server: ServersCache,
|
||||
subscriptionRemarks: String,
|
||||
): ServerRowUiModel {
|
||||
val profile = server.profile
|
||||
return ServerRowUiModel(
|
||||
guid = server.guid,
|
||||
profile = profile,
|
||||
remarks = profile.remarks,
|
||||
statistics = profile.description.nullIfBlank()
|
||||
?: AngConfigManager.generateDescription(profile),
|
||||
typeDescription = serverProtocolDescription(profile),
|
||||
testDelayMillis = server.testDelayMillis,
|
||||
subscriptionBadge = subscriptionRemarks.firstOrNull()?.toString().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun serverProtocolDescription(profile: ProfileItem): String {
|
||||
if (profile.configType.isComplexType()) return profile.configType.name
|
||||
val parts = mutableListOf(profile.configType.name)
|
||||
profile.network?.let { network ->
|
||||
if (network.isNotBlank() && !network.equals("tcp", ignoreCase = true)) {
|
||||
parts.add(network)
|
||||
}
|
||||
}
|
||||
profile.security?.let { security ->
|
||||
if (security.isNotBlank()) {
|
||||
parts.add(
|
||||
if (profile.insecure == true && security.equals("tls", ignoreCase = true)) {
|
||||
"$security insecure"
|
||||
} else {
|
||||
security
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return parts.joinToString(" / ")
|
||||
}
|
||||
@@ -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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -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) }
|
||||
@@ -222,7 +237,7 @@ class MainViewModel(
|
||||
viewModelScope.launch(preloadDispatcher) {
|
||||
try {
|
||||
initialPageReady.await()
|
||||
delay(32L)
|
||||
delay(32)
|
||||
dataSource.initAssets()
|
||||
dataSource.syncSubscriptions()
|
||||
} catch (cancelled: CancellationException) {
|
||||
@@ -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() }
|
||||
@@ -364,7 +404,7 @@ class MainViewModel(
|
||||
preloadJob = viewModelScope.launch(preloadDispatcher) {
|
||||
preloadOrder.forEach { groupId ->
|
||||
ensureActive()
|
||||
delay(32L)
|
||||
delay(32)
|
||||
val servers = loadGroup(groupId, forceRefresh)
|
||||
updateGroupUi(groupId, servers)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -617,7 +657,7 @@ class MainViewModel(
|
||||
}
|
||||
order.forEachIndexed { index, groupId ->
|
||||
ensureActive()
|
||||
if (index > 0) delay(32L)
|
||||
if (index > 0) delay(32)
|
||||
updateGroupUi(groupId, loadGroup(groupId, forceRefresh = true))
|
||||
}
|
||||
}
|
||||
@@ -628,7 +668,7 @@ class MainViewModel(
|
||||
keywordFilter = keyword
|
||||
filterJob?.cancel()
|
||||
filterJob = viewModelScope.launch(defaultDispatcher) {
|
||||
delay(300L)
|
||||
delay(300)
|
||||
val snapshot = cacheMutex.withLock { groupDataCache.toMap() }
|
||||
ensureActive()
|
||||
snapshot.forEach { (groupId, servers) ->
|
||||
@@ -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) {
|
||||
@@ -689,11 +732,23 @@ class MainViewModel(
|
||||
dataSource.cancelAllPing()
|
||||
val groupId = uiState.value.selectedGroupId
|
||||
val servers = currentServers()
|
||||
dataSource.clearAllTestDelayResults(servers.map { it.guid })
|
||||
if (servers.isEmpty()) {
|
||||
_uiState.update { it.copy(isTesting = false) }
|
||||
return
|
||||
}
|
||||
val serverGuids = servers.map { it.guid }
|
||||
mutableServerGroupState(groupId).update { current ->
|
||||
current.copy(
|
||||
servers = current.servers.map { server ->
|
||||
if (server.testDelayMillis == 0L) server
|
||||
else server.copy(testDelayMillis = 0L)
|
||||
},
|
||||
rows = current.rows.map { row ->
|
||||
if (row.testDelayMillis == 0L) row
|
||||
else row.copy(testDelayMillis = 0L)
|
||||
}
|
||||
)
|
||||
}
|
||||
testingGroupId = groupId
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -702,12 +757,13 @@ class MainViewModel(
|
||||
)
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
dataSource.clearAllTestDelayResults(serverGuids)
|
||||
cacheMutex.withLock { groupDataCache.remove(groupId) }
|
||||
dataSource.sendMsg2TestService(
|
||||
TestServiceMessage(
|
||||
key = AppConfig.MSG_MEASURE_CONFIG_START,
|
||||
subscriptionId = groupId,
|
||||
serverGuids = if (keywordFilter.isNotEmpty()) servers.map { it.guid } else emptyList(),
|
||||
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
|
||||
onlyTcp = onlyTcp
|
||||
)
|
||||
)
|
||||
@@ -737,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 ----------
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
@@ -49,6 +49,7 @@ import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppListItem
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -136,7 +137,7 @@ fun PerAppProxyScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.per_app_proxy_settings),
|
||||
@@ -167,7 +168,7 @@ fun PerAppProxyScreen(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
contentDescription = stringResource(R.string.acc_more)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -263,7 +264,8 @@ fun PerAppProxyScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = blacklist.contains(app.packageName)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -18,7 +19,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -46,6 +46,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -178,7 +179,7 @@ fun RoutingEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.routing_settings_rule_title),
|
||||
@@ -302,6 +303,7 @@ fun RoutingEditScreen(
|
||||
editable = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -22,7 +23,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -54,6 +54,7 @@ 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
|
||||
@@ -215,7 +216,7 @@ fun RoutingSettingScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.routing_settings_title),
|
||||
@@ -231,7 +232,7 @@ fun RoutingSettingScreen(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
contentDescription = stringResource(R.string.acc_more)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -259,7 +260,8 @@ fun RoutingSettingScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(lazyListState)
|
||||
.verticalScrollbar(lazyListState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
item(key = "domain_strategy") {
|
||||
SettingsListItem(
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -14,7 +15,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -44,6 +44,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
@@ -448,7 +449,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = title,
|
||||
@@ -481,9 +482,11 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
.verticalScroll(scrollState)
|
||||
.verticalScrollbar(scrollState)
|
||||
.padding(bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content
|
||||
)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
content()
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmDialog(
|
||||
|
||||
@@ -3,9 +3,12 @@ package com.v2ray.ang.ui.server
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -13,7 +16,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -339,7 +341,7 @@ fun ServerScreen(
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = configType.toString(),
|
||||
@@ -367,7 +369,7 @@ fun ServerScreen(
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.imePadding()
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = PaddingValues(bottom = 36.dp),
|
||||
contentPadding = PaddingValues(bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item { FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it }) }
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -25,7 +26,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -66,6 +66,7 @@ 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.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.horizontalScrollbar
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -310,7 +311,7 @@ fun ServerCustomConfigScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.CUSTOM.toString(),
|
||||
@@ -478,6 +479,7 @@ fun ServerCustomConfigScreen(
|
||||
.horizontalScrollbar(scrollState = horizontalScroll)
|
||||
)
|
||||
}
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.v2ray.ang.ui.server
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -11,7 +12,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -39,6 +39,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
|
||||
class ServerGroupActivity : BaseComponentActivity() {
|
||||
@@ -237,7 +238,7 @@ fun ServerGroupScreen(
|
||||
val supportsObservatory = BalancerStrategyType.from(selectedType).supportsObservatory
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.POLICYGROUP.toString(),
|
||||
@@ -297,6 +298,7 @@ fun ServerGroupScreen(
|
||||
editable = true
|
||||
)
|
||||
}
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,10 +4,13 @@ import android.os.Bundle
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,7 +23,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -220,7 +222,7 @@ fun ProxyChainScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.PROXYCHAIN.toString(),
|
||||
@@ -263,7 +265,7 @@ fun ProxyChainScreen(
|
||||
top = 8.dp,
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 36.dp
|
||||
bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item(key = "remarks_field") {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -12,7 +16,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -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
|
||||
@@ -37,13 +44,16 @@ import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.CollapsiblePreferenceGroupHeader
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsEditItem
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
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() {
|
||||
|
||||
@@ -51,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
|
||||
@@ -58,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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -68,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) }
|
||||
@@ -85,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)
|
||||
@@ -109,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)
|
||||
|
||||
@@ -118,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)
|
||||
@@ -141,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, "")
|
||||
@@ -152,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) {
|
||||
@@ -186,7 +219,7 @@ fun SettingsScreen(
|
||||
val modeValues = stringArrayResource(R.array.mode_value).toList()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_settings),
|
||||
@@ -611,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,
|
||||
@@ -677,6 +717,7 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -19,7 +20,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -34,14 +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.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)
|
||||
@@ -49,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +68,7 @@ class TaskerActivity : BaseComponentActivity() {
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
TaskerScreen(
|
||||
items = lstData,
|
||||
items = items,
|
||||
switchState = switchState,
|
||||
selectedPosition = selectedPosition,
|
||||
onBackClick = { finish() },
|
||||
@@ -83,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)
|
||||
@@ -101,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)
|
||||
@@ -119,7 +118,7 @@ class TaskerActivity : BaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
fun TaskerScreen(
|
||||
items: List<String>,
|
||||
items: List<TaskerItem>,
|
||||
switchState: MutableState<Boolean>,
|
||||
selectedPosition: MutableState<Int>,
|
||||
onBackClick: () -> Unit,
|
||||
@@ -127,7 +126,7 @@ fun TaskerScreen(
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = "",
|
||||
@@ -154,9 +153,10 @@ fun TaskerScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(items) { index, remarks ->
|
||||
itemsIndexed(items, key = { _, item -> item.guid }) { index, item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -169,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,6 +3,7 @@ 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.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -12,7 +13,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,6 +40,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -163,7 +164,7 @@ fun SubEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_sub_setting),
|
||||
@@ -239,6 +240,7 @@ fun SubEditScreen(
|
||||
editable = true,
|
||||
supportingText = stringResource(R.string.sub_setting_exit_proxy_tip)
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -47,6 +47,7 @@ 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
|
||||
@@ -126,7 +127,7 @@ fun SubSettingScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_sub_setting),
|
||||
@@ -148,7 +149,8 @@ fun SubSettingScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(lazyListState)
|
||||
.verticalScrollbar(lazyListState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = subscriptions,
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -25,10 +26,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -57,6 +56,7 @@ 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.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -75,13 +75,14 @@ private enum class AddAssetMenuAction(@StringRes val labelRes: Int) {
|
||||
QRCode(R.string.menu_item_scan_qrcode)
|
||||
}
|
||||
|
||||
private data class AssetDeleteTarget(val guid: String, val name: String)
|
||||
|
||||
class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
|
||||
private val viewModel: UserAssetViewModel by viewModels()
|
||||
val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||
private val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||
private val isLoadingState = MutableStateFlow(false)
|
||||
private val geoFilesSourceState = MutableStateFlow("")
|
||||
private val refreshTrigger = MutableStateFlow(0)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -90,12 +91,13 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
val isLoading by isLoadingState.collectAsStateWithLifecycle()
|
||||
val geoFilesSource by geoFilesSourceState.collectAsStateWithLifecycle()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
UserAssetScreen(
|
||||
viewModel = viewModel,
|
||||
extDir = extDir,
|
||||
isLoadingState = isLoadingState,
|
||||
geoFilesSourceState = geoFilesSourceState,
|
||||
refreshTrigger = refreshTrigger,
|
||||
uiState = uiState,
|
||||
isLoading = isLoading,
|
||||
geoFilesSource = geoFilesSource,
|
||||
geoFilesSourcesList = AppConfig.GEO_FILES_SOURCES.toList(),
|
||||
onBackClick = { finish() },
|
||||
onGeoSourceSelected = { value ->
|
||||
@@ -110,13 +112,10 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
onEditAsset = { guid ->
|
||||
startActivity(Intent(this, UserAssetUrlActivity::class.java).putExtra("assetId", guid))
|
||||
},
|
||||
onRemoveAsset = { guid ->
|
||||
val asset = viewModel.getAssets().find { it.guid == guid }
|
||||
if (asset != null) {
|
||||
extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }?.delete()
|
||||
MmkvManager.removeAssetUrl(guid)
|
||||
initAssets()
|
||||
}
|
||||
onRemoveAsset = { guid, name ->
|
||||
extDir.listFiles()?.find { it.name == name }?.delete()
|
||||
MmkvManager.removeAssetUrl(guid)
|
||||
initAssets()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -208,28 +207,28 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
|
||||
private fun downloadGeoFiles() {
|
||||
refreshData()
|
||||
isLoadingState.value = true
|
||||
toast(R.string.msg_downloading_content)
|
||||
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.successCount))
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
refreshData()
|
||||
isLoadingState.value = false
|
||||
lifecycleScope.launch {
|
||||
refreshData().join()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||
}
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_asset_count, result.successCount))
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
refreshData().join()
|
||||
isLoadingState.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun initAssets() {
|
||||
private fun initAssets() {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
SettingsManager.initAssets(this@UserAssetActivity, assets)
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -238,19 +237,14 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshData() {
|
||||
viewModel.reload(getGeoFilesSources())
|
||||
refreshTrigger.value++
|
||||
}
|
||||
private fun refreshData() = viewModel.reload(getGeoFilesSources(), extDir)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserAssetScreen(
|
||||
viewModel: UserAssetViewModel,
|
||||
extDir: File,
|
||||
isLoadingState: MutableStateFlow<Boolean>,
|
||||
geoFilesSourceState: MutableStateFlow<String>,
|
||||
refreshTrigger: MutableStateFlow<Int>,
|
||||
internal fun UserAssetScreen(
|
||||
uiState: UserAssetUiState,
|
||||
isLoading: Boolean,
|
||||
geoFilesSource: String,
|
||||
geoFilesSourcesList: List<String>,
|
||||
onBackClick: () -> Unit,
|
||||
onGeoSourceSelected: (String) -> Unit,
|
||||
@@ -259,19 +253,14 @@ fun UserAssetScreen(
|
||||
onAddQrcodeClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onEditAsset: (String) -> Unit,
|
||||
onRemoveAsset: (String) -> Unit
|
||||
onRemoveAsset: (String, String) -> Unit
|
||||
) {
|
||||
val isLoading by isLoadingState.collectAsState()
|
||||
val geoFilesSource by geoFilesSourceState.collectAsState()
|
||||
val assets by viewModel.assetsFlow.collectAsStateWithLifecycle()
|
||||
val trigger by refreshTrigger.collectAsState()
|
||||
|
||||
var showAddMenu by remember { mutableStateOf(false) }
|
||||
var deleteTargetGuid by remember { mutableStateOf<String?>(null) }
|
||||
var deleteTarget by remember { mutableStateOf<AssetDeleteTarget?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_user_asset_setting),
|
||||
@@ -311,9 +300,10 @@ fun UserAssetScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
item(key = "geo_source_$trigger") {
|
||||
item(key = "geo_source") {
|
||||
SettingsListItem(
|
||||
title = stringResource(R.string.asset_geo_files_sources),
|
||||
entries = geoFilesSourcesList,
|
||||
@@ -329,12 +319,14 @@ fun UserAssetScreen(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
itemsIndexed(items = assets, key = { _, item -> "${item.guid}_$trigger" }) { _, item ->
|
||||
itemsIndexed(items = uiState.assets, key = { _, item -> item.guid }) { _, item ->
|
||||
UserAssetItem(
|
||||
item = item,
|
||||
extDir = extDir,
|
||||
fileMetadata = uiState.fileMetadata[item.guid],
|
||||
onEdit = { onEditAsset(item.guid) },
|
||||
onDeleteClick = { deleteTargetGuid = item.guid }
|
||||
onDeleteClick = {
|
||||
deleteTarget = AssetDeleteTarget(item.guid, item.assetUrl.remarks)
|
||||
}
|
||||
)
|
||||
ItemDivider()
|
||||
}
|
||||
@@ -342,13 +334,14 @@ fun UserAssetScreen(
|
||||
}
|
||||
|
||||
|
||||
if (deleteTargetGuid != null) {
|
||||
val guid = deleteTargetGuid!!
|
||||
val assetName = assets.find { it.guid == guid }?.assetUrl?.remarks ?: ""
|
||||
deleteTarget?.let { asset ->
|
||||
DeleteConfirmDialog(
|
||||
message = stringResource(R.string.confirm_delete_asset_file, assetName),
|
||||
onConfirm = { onRemoveAsset(guid) },
|
||||
onDismiss = { deleteTargetGuid = null }
|
||||
message = stringResource(R.string.confirm_delete_asset_file, asset.name),
|
||||
onConfirm = {
|
||||
deleteTarget = null
|
||||
onRemoveAsset(asset.guid, asset.name)
|
||||
},
|
||||
onDismiss = { deleteTarget = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -356,16 +349,15 @@ fun UserAssetScreen(
|
||||
@Composable
|
||||
private fun UserAssetItem(
|
||||
item: AssetUrlCache,
|
||||
extDir: File,
|
||||
fileMetadata: AssetFileMetadata?,
|
||||
onEdit: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
val file = remember(item.guid, item.assetUrl.remarks) {
|
||||
extDir.listFiles()?.find { it.name == item.assetUrl.remarks }
|
||||
}
|
||||
val propertiesText = if (file != null) {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
|
||||
val propertiesText = if (fileMetadata != null) {
|
||||
remember(fileMetadata) {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
"${fileMetadata.length.toTrafficString()} • ${dateFormat.format(Date(fileMetadata.lastModified))}"
|
||||
}
|
||||
} else {
|
||||
stringResource(R.string.msg_file_not_found)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ 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.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -28,6 +28,7 @@ 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.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
@@ -142,7 +143,7 @@ fun UserAssetUrlScreen(
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_user_asset_add_url),
|
||||
@@ -182,6 +183,7 @@ fun UserAssetUrlScreen(
|
||||
value = url,
|
||||
onValueChange = { url = it }
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.v2ray.ang.ui.userasset
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
@@ -11,30 +12,42 @@ import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
internal data class AssetFileMetadata(val length: Long, val lastModified: Long)
|
||||
|
||||
internal data class UserAssetUiState(
|
||||
val assets: List<AssetUrlCache> = emptyList(),
|
||||
val fileMetadata: Map<String, AssetFileMetadata> = emptyMap()
|
||||
)
|
||||
|
||||
class UserAssetViewModel(application: Application) : BaseViewModel(application) {
|
||||
private val assets = mutableListOf<AssetUrlCache>()
|
||||
private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||
|
||||
private val _assetsFlow = MutableStateFlow<List<AssetUrlCache>>(emptyList())
|
||||
val assetsFlow: StateFlow<List<AssetUrlCache>> = _assetsFlow.asStateFlow()
|
||||
private val _uiState = MutableStateFlow(UserAssetUiState())
|
||||
internal val uiState: StateFlow<UserAssetUiState> = _uiState.asStateFlow()
|
||||
private var reloadJob: Job? = null
|
||||
|
||||
val itemCount: Int
|
||||
get() = assets.size
|
||||
|
||||
fun getAssets(): List<AssetUrlCache> = assets.toList()
|
||||
|
||||
fun getAsset(position: Int): AssetUrlCache? = assets.getOrNull(position)
|
||||
|
||||
fun reload(geoFilesSource: String) {
|
||||
val decoded = MmkvManager.decodeAssetUrls()
|
||||
assets.clear()
|
||||
assets.addAll(buildAssetList(decoded, geoFilesSource))
|
||||
_assetsFlow.value = assets.toList()
|
||||
fun reload(geoFilesSource: String, extDir: File): Job {
|
||||
reloadJob?.cancel()
|
||||
return viewModelScope.launch(Dispatchers.IO) {
|
||||
val snapshot = buildAssetList(MmkvManager.decodeAssetUrls(), geoFilesSource)
|
||||
val files = extDir.listFiles().orEmpty().associateBy { it.name }
|
||||
val metadata = snapshot.mapNotNull { asset ->
|
||||
files[asset.assetUrl.remarks]?.let { file ->
|
||||
asset.guid to AssetFileMetadata(file.length(), file.lastModified())
|
||||
}
|
||||
}.toMap()
|
||||
ensureActive()
|
||||
_uiState.value = UserAssetUiState(snapshot, metadata)
|
||||
}.also { reloadJob = it }
|
||||
}
|
||||
|
||||
private fun buildAssetList(
|
||||
@@ -74,7 +87,7 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
|
||||
proxyUsername: String? = null,
|
||||
proxyPassword: String? = null
|
||||
): GeoDownloadResult {
|
||||
val snapshot = getAssets()
|
||||
val snapshot = uiState.value.assets
|
||||
var successCount = 0
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
@@ -127,4 +140,4 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
|
||||
val failureCount: Int,
|
||||
val failedAssets: List<String>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">استيراد %d من التكوينات</string>
|
||||
<string name="title_export_config_count">تصدير %d من التكوينات</string>
|
||||
<string name="title_update_config_count">تحديث %d من التكوينات</string>
|
||||
<string name="title_update_asset_count">تم تحديث %d من ملفات الأصول</string>
|
||||
<string name="title_update_subscription_result">تم تحديث %1$d إعدادات (%2$d ناجحة، %3$d فاشلة، %4$d متخطاة)</string>
|
||||
<string name="title_update_subscription_no_subscription">لا توجد اشتراكات</string>
|
||||
<string name="toast_server_not_found_in_group">لم يُعثر على الخادم المحدد في المجموعة الحالية</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">النسخ الاحتياطي والاستعادة</string>
|
||||
<string name="title_configuration_backup">نسخ التكوين احتياطيًا</string>
|
||||
<string name="title_configuration_restore">استعادة التكوين</string>
|
||||
<string name="title_profile_storage_cleanup">إصلاح بيانات ملفات التعريف</string>
|
||||
<string name="summary_profile_storage_cleanup">حذف بيانات ملفات التعريف غير المستخدمة</string>
|
||||
<string name="message_profile_storage_cleanup">هل تريد حذف بيانات ملفات التعريف التي لم تعد مستخدمة في أي مجموعة؟ سيُحتفظ بملف التعريف المحدد وأي بيانات يتعذر التأكد من أنها غير مستخدمة.</string>
|
||||
<string name="toast_profile_storage_cleanup">تم حذف ملفات التعريف غير المستخدمة: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">تعذّر تحديد بيانات ملفات التعريف غير المستخدمة بأمان. لم يُحذف أي شيء.</string>
|
||||
<string name="title_configuration_share">مشاركة التكوين</string>
|
||||
<string name="title_webdav_config_setting">إعدادات WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">يرجى إعداد WebDAV أولاً.</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">بحث</string>
|
||||
<string name="acc_add">إضافة تكوين</string>
|
||||
<string name="acc_more">المزيد من الخيارات</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">مشاركة رابط الاشتراك</string>
|
||||
<string name="acc_share_log">مشاركة السجل</string>
|
||||
<string name="acc_remove">إزالة عضو من سلسلة الوكلاء</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>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">%dটি কনফিগারেশন আমদানি করুন</string>
|
||||
<string name="title_export_config_count">%dটি কনফিগারেশন রপ্তানি করুন</string>
|
||||
<string name="title_update_config_count">%dটি কনফিগারেশন আপডেট করুন</string>
|
||||
<string name="title_update_asset_count">%dটি অ্যাসেট ফাইল আপডেট হয়েছে</string>
|
||||
<string name="title_update_subscription_result">%1$dটি কনফিগ আপডেট হয়েছে (%2$d সফল, %3$d ব্যর্থ, %4$d এড়ানো)</string>
|
||||
<string name="title_update_subscription_no_subscription">কোনো সাবস্ক্রিপশন নেই</string>
|
||||
<string name="toast_server_not_found_in_group">বর্তমান গ্রুপে নির্বাচিত সার্ভারটি পাওয়া যায়নি</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">ব্যাকআপ ও পুনরুদ্ধার</string>
|
||||
<string name="title_configuration_backup">কনফিগারেশন ব্যাকআপ</string>
|
||||
<string name="title_configuration_restore">কনফিগারেশন পুনরুদ্ধার</string>
|
||||
<string name="title_profile_storage_cleanup">প্রোফাইল স্টোরেজ মেরামত করুন</string>
|
||||
<string name="summary_profile_storage_cleanup">অব্যবহৃত প্রোফাইল ডেটা মুছে ফেলুন</string>
|
||||
<string name="message_profile_storage_cleanup">কোনো গ্রুপে আর ব্যবহৃত হয় না এমন প্রোফাইল ডেটা মুছে ফেলবেন? নির্বাচিত প্রোফাইল এবং নিরাপদে অব্যবহৃত বলে নির্ধারণ করা যায় না এমন ডেটা রাখা হবে।</string>
|
||||
<string name="toast_profile_storage_cleanup">অব্যবহৃত প্রোফাইল মুছে ফেলা হয়েছে: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">কোন প্রোফাইল ডেটা অব্যবহৃত তা নিরাপদে নির্ধারণ করা যায়নি। কিছুই মুছে ফেলা হয়নি।</string>
|
||||
<string name="title_configuration_share">কনফিগারেশন শেয়ার করুন</string>
|
||||
<string name="title_webdav_config_setting">WebDAV সেটিংস</string>
|
||||
<string name="title_webdav_config_setting_unknown">প্রথমে WebDAV কনফিগার করুন।</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">অনুসন্ধান করুন</string>
|
||||
<string name="acc_add">কনফিগারেশন যোগ করুন</string>
|
||||
<string name="acc_more">আরও বিকল্প</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">সাবস্ক্রিপশন লিংক শেয়ার করুন</string>
|
||||
<string name="acc_share_log">লগ শেয়ার করুন</string>
|
||||
<string name="acc_remove">প্রক্সি চেইনের সদস্য সরান</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>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">و من ٱووردن %d کانفیگ</string>
|
||||
<string name="title_export_config_count">و در کشیڌن %d کانفیگ</string>
|
||||
<string name="title_update_config_count">ورۊ کردن %d کانفیگ</string>
|
||||
<string name="title_update_asset_count">%d فایل بونچک جغرافیایی ورۊ وابی</string>
|
||||
<string name="title_update_subscription_result">%1$d کانفیگ ورۊ وابی (مووفق: %2$d، شکست خرده: %3$d، رڌ وابیڌه: %4$d)</string>
|
||||
<string name="title_update_subscription_no_subscription">بؽ اشتراک</string>
|
||||
<string name="toast_server_not_found_in_group">سرور پسند وابیڌه من بونکۊ سکویی نجۊرست</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">لادراری گرؽڌن & وورگندن</string>
|
||||
<string name="title_configuration_backup">لادراری گرؽڌن ز کانفیگ</string>
|
||||
<string name="title_configuration_restore">وورگندن کانفیگ</string>
|
||||
<string name="title_profile_storage_cleanup">جۊر کردن داڌه یل کانفیگا</string>
|
||||
<string name="summary_profile_storage_cleanup">پاک کردن داڌه یل کانفیگا ک دی و کار نؽن</string>
|
||||
<string name="message_profile_storage_cleanup">داڌه یل کانفیگا ک دی من هیچ بونکۊی و کار نؽن پاک ابۊن، هنی هم اخۊی پاکسووݩ کۊنی؟ کانفیگا پسند وابیڌه وو داڌه یلی ک نتری وا موطمئنی بفهمی دی و کار نؽن پاک نابۊن.</string>
|
||||
<string name="toast_profile_storage_cleanup">کانفیگا ک دی و کار نؽن پاک وابیڌن: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">نتری وا موطمئنی بفهمی ک کۊی داڌه یل کانفیگا دی و کار نؽن. هیچ داڌه ای پاک نوابی.</string>
|
||||
<string name="title_configuration_share">یک رسۊوی کانفیگ</string>
|
||||
<string name="title_webdav_config_setting">WebDAV سامووا</string>
|
||||
<string name="title_webdav_config_setting_unknown">ٱول WebDAV ن کانفیگ کۊنین</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">پیتینیڌن</string>
|
||||
<string name="acc_add">ٱووردن کانفیگ</string>
|
||||
<string name="acc_more">امکووݩا بؽتر</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">یک رسۊوی لینک اشتراک</string>
|
||||
<string name="acc_share_log">یک رسۊوی گوزارش</string>
|
||||
<string name="acc_remove">پاک کردن عوزو زنجیره پروکسی</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>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">وارد کردن %d کانفیگ</string>
|
||||
<string name="title_export_config_count">صادر کردن %d کانفیگ</string>
|
||||
<string name="title_update_config_count">آپدیت کردن %d کانفیگ</string>
|
||||
<string name="title_update_asset_count">%d فایل منبع بروزرسانی شد</string>
|
||||
<string name="title_update_subscription_result">بروزرسانی %1$d کانفیگ انجام شد (%2$d موفق، %3$d شکست خورده، %4$d نادیده گرفته شده)</string>
|
||||
<string name="title_update_subscription_no_subscription">هیچ اشتراکی وجود ندارد</string>
|
||||
<string name="toast_server_not_found_in_group">سرور انتخابشده در گروه فعلی پیدا نشد</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">پشتیبانگیری و بازیابی</string>
|
||||
<string name="title_configuration_backup">پشتیبان گیری از پیکربندی</string>
|
||||
<string name="title_configuration_restore">بازیابی پیکربندی</string>
|
||||
<string name="title_profile_storage_cleanup">ترمیم فضای ذخیرهسازی پروفایلها</string>
|
||||
<string name="summary_profile_storage_cleanup">حذف دادههای پروفایل بلااستفاده</string>
|
||||
<string name="message_profile_storage_cleanup">دادههای پروفایلی که دیگر در هیچ گروهی استفاده نمیشوند حذف شوند؟ پروفایل انتخابشده و دادههایی که نمیتوان با اطمینان بلااستفاده بودنشان را تشخیص داد حفظ خواهند شد.</string>
|
||||
<string name="toast_profile_storage_cleanup">پروفایلهای بلااستفاده حذف شدند: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">امکان تشخیص مطمئن دادههای پروفایل بلااستفاده وجود نداشت. هیچ موردی حذف نشد.</string>
|
||||
<string name="title_configuration_share">اشتراک گذاری پیکربندی</string>
|
||||
<string name="title_webdav_config_setting">تنظیمات WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">لطفاً ابتدا WebDAV را پیکربندی کنید.</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">جستجو</string>
|
||||
<string name="acc_add">افزودن کانفیگ</string>
|
||||
<string name="acc_more">گزینههای بیشتر</string>
|
||||
<string name="acc_selected_server">انتخاب شده</string>
|
||||
<string name="acc_share_subscription">اشتراکگذاری لینک اشتراک</string>
|
||||
<string name="acc_share_log">اشتراکگذاری گزارش</string>
|
||||
<string name="acc_remove">حذف عضو زنجیره پروکسی</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>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">Импортировано профилей: %d</string>
|
||||
<string name="title_export_config_count">Экспортировано профилей: %d</string>
|
||||
<string name="title_update_config_count">Обновлено профилей: %d</string>
|
||||
<string name="title_update_asset_count">Обновлено файлов ресурсов: %d</string>
|
||||
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибки: %3$d, пропущено: %4$d)</string>
|
||||
<string name="title_update_subscription_no_subscription">Нет подписок</string>
|
||||
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">Резервное копирование и восстановление</string>
|
||||
<string name="title_configuration_backup">Создать резервную копию</string>
|
||||
<string name="title_configuration_restore">Восстановление конфигурации</string>
|
||||
<string name="title_profile_storage_cleanup">Исправить хранилище профилей</string>
|
||||
<string name="summary_profile_storage_cleanup">Удалить данные неиспользуемых профилей</string>
|
||||
<string name="message_profile_storage_cleanup">Удалить данные профилей, которые больше не используются ни в одной группе? Выбранный профиль и данные, которые нельзя с уверенностью отнести к неиспользуемым, будут сохранены.</string>
|
||||
<string name="toast_profile_storage_cleanup">Удалено неиспользуемых профилей: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">Не удалось достоверно определить неиспользуемые данные профилей. Ничего не удалено.</string>
|
||||
<string name="title_configuration_share">Поделиться конфигурацией</string>
|
||||
<string name="title_webdav_config_setting">Настройки WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">Поиск</string>
|
||||
<string name="acc_add">Добавить профиль</string>
|
||||
<string name="acc_more">Дополнительные параметры</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">Поделиться ссылкой на подписку</string>
|
||||
<string name="acc_share_log">Поделиться журналом</string>
|
||||
<string name="acc_remove">Удалить участника цепочки прокси</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>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">Nhập %d cấu hình</string>
|
||||
<string name="title_export_config_count">Xuất %d cấu hình</string>
|
||||
<string name="title_update_config_count">Cập nhật %d cấu hình</string>
|
||||
<string name="title_update_asset_count">Đã cập nhật %d tệp tài nguyên</string>
|
||||
<string name="title_update_subscription_result">Đã cập nhật %1$d cấu hình (%2$d thành công, %3$d thất bại, %4$d bỏ qua)</string>
|
||||
<string name="title_update_subscription_no_subscription">Không có đăng ký</string>
|
||||
<string name="toast_server_not_found_in_group">Không tìm thấy máy chủ đã chọn trong nhóm hiện tại</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">Sao lưu & khôi phục</string>
|
||||
<string name="title_configuration_backup">Sao lưu cấu hình</string>
|
||||
<string name="title_configuration_restore">Khôi phục cấu hình</string>
|
||||
<string name="title_profile_storage_cleanup">Sửa dữ liệu cấu hình</string>
|
||||
<string name="summary_profile_storage_cleanup">Xóa dữ liệu cấu hình không còn được sử dụng</string>
|
||||
<string name="message_profile_storage_cleanup">Xóa dữ liệu cấu hình không còn được nhóm nào sử dụng? Cấu hình đang chọn và dữ liệu không thể xác định chắc chắn là không còn được sử dụng sẽ được giữ lại.</string>
|
||||
<string name="toast_profile_storage_cleanup">Đã xóa cấu hình không dùng: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">Không thể xác định chắc chắn dữ liệu cấu hình không còn được sử dụng. Không có dữ liệu nào bị xóa.</string>
|
||||
<string name="title_configuration_share">Chia sẻ cấu hình</string>
|
||||
<string name="title_webdav_config_setting">Cài đặt WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">Vui lòng cấu hình WebDAV trước.</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">Tìm kiếm</string>
|
||||
<string name="acc_add">Thêm cấu hình</string>
|
||||
<string name="acc_more">Tùy chọn khác</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">Chia sẻ liên kết đăng ký</string>
|
||||
<string name="acc_share_log">Chia sẻ nhật ký</string>
|
||||
<string name="acc_remove">Xóa thành viên khỏi chuỗi proxy</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>
|
||||
@@ -128,7 +128,7 @@
|
||||
<string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string>
|
||||
<string name="server_lab_final_mask">FinalMask 原始 JSON 格式: { FinalMaskObject }</string>
|
||||
<string name="server_lab_ech_config_list" translatable="false">echConfigList</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">按名称验证对端证书</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">Verify peer cert by name</string>
|
||||
<string name="server_lab_pinned_ca256">证书指纹 (SHA-256)</string>
|
||||
<string name="server_lab_browser_dialer">启用 Browser Dialer</string>
|
||||
<string name="server_lab_browser_dialer_tip">仅支持 xhttp(packet-up)和 ws 出站;TLS 相关设置可能会被忽略或产生冲突</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>
|
||||
@@ -233,7 +236,7 @@
|
||||
<string name="title_pref_dns_hosts">DNS 主机映射(格式:domain:address,…)</string>
|
||||
<string name="summary_pref_dns_hosts">domain: address,…</string>
|
||||
|
||||
<string name="title_pref_delay_test_url">真实延迟测试 URL</string>
|
||||
<string name="title_pref_delay_test_url">真连接延迟测试 URL</string>
|
||||
<string name="summary_pref_delay_test_url" translatable="false">URL</string>
|
||||
<string name="title_pref_real_ping_concurrency">真连接延迟并发测试数</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>
|
||||
@@ -337,7 +341,7 @@
|
||||
<string name="toast_invalid_update_interval">更新间隔无效,最小值为 15 分钟。</string>
|
||||
<string name="title_sub_update">更新订阅</string>
|
||||
<string name="title_ping_all_server">测试 TCP 延迟(TCPing)</string>
|
||||
<string name="title_real_ping_all_server">测试实际延迟</string>
|
||||
<string name="title_real_ping_all_server">测试真连接延迟</string>
|
||||
<string name="title_user_asset_setting">资源文件</string>
|
||||
<string name="title_sort_by_test_results">按测试结果排序</string>
|
||||
<string name="title_filter_config">过滤配置</string>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">导入 %d 个配置</string>
|
||||
<string name="title_export_config_count">导出 %d 个配置</string>
|
||||
<string name="title_update_config_count">更新 %d 个配置</string>
|
||||
<string name="title_update_asset_count">已更新 %d 个资源文件</string>
|
||||
<string name="title_update_subscription_result">更新了 %1$d 个配置(%2$d 个成功,%3$d 个失败,%4$d 个跳过)</string>
|
||||
<string name="title_update_subscription_no_subscription">无订阅</string>
|
||||
<string name="toast_server_not_found_in_group">当前分组中未找到选中的服务器</string>
|
||||
@@ -386,7 +391,7 @@
|
||||
<string name="connection_test_testing">"测试中…"</string>
|
||||
<string name="connection_test_testing_count">测试 %d 个配置中…</string>
|
||||
<string name="connection_test_available">"连接成功:延时 %1$s"</string>
|
||||
<string name="server_test_delay_value">%1$d 毫秒</string>
|
||||
<string name="server_test_delay_value">%1$d ms</string>
|
||||
<string name="connection_test_error">连接测试失败:%s</string>
|
||||
<string name="connection_test_fail">无法访问互联网</string>
|
||||
<string name="connection_test_error_status_code">错误码:#%d</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">备份 & 还原</string>
|
||||
<string name="title_configuration_backup">备份配置</string>
|
||||
<string name="title_configuration_restore">还原配置</string>
|
||||
<string name="title_profile_storage_cleanup">修复配置存储</string>
|
||||
<string name="summary_profile_storage_cleanup">删除未使用的配置数据</string>
|
||||
<string name="message_profile_storage_cleanup">要删除已不再被任何分组使用的配置数据吗?当前选中的配置以及无法安全判定为未使用的数据将会保留。</string>
|
||||
<string name="toast_profile_storage_cleanup">已删除未使用的配置:%1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">无法安全判定哪些配置数据未被使用。未删除任何数据。</string>
|
||||
<string name="title_configuration_share">分享配置</string>
|
||||
<string name="title_webdav_config_setting">WebDAV 设置</string>
|
||||
<string name="title_webdav_config_setting_unknown">请先设置 WebDAV</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">搜索</string>
|
||||
<string name="acc_add">添加配置</string>
|
||||
<string name="acc_more">更多选项</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">分享订阅链接</string>
|
||||
<string name="acc_share_log">分享日志</string>
|
||||
<string name="acc_remove">移除代理链成员</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>
|
||||
@@ -128,7 +128,7 @@
|
||||
<string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string>
|
||||
<string name="server_lab_final_mask">finalMask 原始 JSON,格式:{ FinalMaskObject }</string>
|
||||
<string name="server_lab_ech_config_list" translatable="false">echConfigList</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">依名稱驗證對端憑證</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">Verify peer cert by name</string>
|
||||
<string name="server_lab_pinned_ca256">憑證指紋 (SHA-256)</string>
|
||||
<string name="server_lab_browser_dialer">啟用 Browser Dialer</string>
|
||||
<string name="server_lab_browser_dialer_tip">僅支援 xhttp (packet-up) 和 ws 出站;與 TLS 相關的設定可能會被忽略或發生衝突</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>
|
||||
@@ -233,7 +236,7 @@
|
||||
<string name="title_pref_dns_hosts">DNS 主機對應(格式:domain:address,…)</string>
|
||||
<string name="summary_pref_dns_hosts">domain:address,…</string>
|
||||
|
||||
<string name="title_pref_delay_test_url">真實延遲測試 URL</string>
|
||||
<string name="title_pref_delay_test_url">真連接延遲測試 URL</string>
|
||||
<string name="summary_pref_delay_test_url" translatable="false">URL</string>
|
||||
<string name="title_pref_real_ping_concurrency">同時執行的真實延遲測試數</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>
|
||||
@@ -337,7 +341,7 @@
|
||||
<string name="toast_invalid_update_interval">更新間隔無效,最小值為 15 分鐘。</string>
|
||||
<string name="title_sub_update">更新訂閱</string>
|
||||
<string name="title_ping_all_server">測試 TCP 延遲(TCPing)</string>
|
||||
<string name="title_real_ping_all_server">測試實際延遲</string>
|
||||
<string name="title_real_ping_all_server">測試真連接延遲</string>
|
||||
<string name="title_user_asset_setting">資源檔案</string>
|
||||
<string name="title_sort_by_test_results">依測試結果排序</string>
|
||||
<string name="title_filter_config">篩選設定</string>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">匯入 %d 個設定</string>
|
||||
<string name="title_export_config_count">匯出 %d 個設定</string>
|
||||
<string name="title_update_config_count">更新 %d 個設定</string>
|
||||
<string name="title_update_asset_count">已更新 %d 個資源檔案</string>
|
||||
<string name="title_update_subscription_result">更新了 %1$d 個設定(%2$d 個成功,%3$d 個失敗,%4$d 個跳過)</string>
|
||||
<string name="title_update_subscription_no_subscription">無訂閱</string>
|
||||
<string name="toast_server_not_found_in_group">目前群組中找不到所選伺服器</string>
|
||||
@@ -386,7 +391,7 @@
|
||||
<string name="connection_test_testing">"測試中……"</string>
|
||||
<string name="connection_test_testing_count">正在測試 %d 個設定…</string>
|
||||
<string name="connection_test_available">"連線成功:延遲 %1$s"</string>
|
||||
<string name="server_test_delay_value">%1$d 毫秒</string>
|
||||
<string name="server_test_delay_value">%1$d ms</string>
|
||||
<string name="connection_test_error">連線測試失敗:%s</string>
|
||||
<string name="connection_test_fail">"無法使用網際網路"</string>
|
||||
<string name="connection_test_error_status_code">錯誤碼:#%d</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">備份與還原</string>
|
||||
<string name="title_configuration_backup">備份設定</string>
|
||||
<string name="title_configuration_restore">還原設定</string>
|
||||
<string name="title_profile_storage_cleanup">修復設定檔儲存空間</string>
|
||||
<string name="summary_profile_storage_cleanup">刪除未使用的設定檔資料</string>
|
||||
<string name="message_profile_storage_cleanup">要刪除已不再由任何群組使用的設定檔資料嗎?目前選取的設定檔,以及無法安全判定為未使用的資料,都會予以保留。</string>
|
||||
<string name="toast_profile_storage_cleanup">已刪除未使用的設定檔:%1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">無法安全判定哪些設定檔資料未被使用。未刪除任何資料。</string>
|
||||
<string name="title_configuration_share">分享設定</string>
|
||||
<string name="title_webdav_config_setting">WebDAV 設定</string>
|
||||
<string name="title_webdav_config_setting_unknown">請先設定 WebDAV</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">搜尋</string>
|
||||
<string name="acc_add">新增設定</string>
|
||||
<string name="acc_more">更多選項</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">分享訂閱連結</string>
|
||||
<string name="acc_share_log">分享日誌</string>
|
||||
<string name="acc_remove">移除代理鏈成員</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>
|
||||
@@ -348,6 +352,7 @@
|
||||
<string name="title_import_config_count">Import %d configs</string>
|
||||
<string name="title_export_config_count">Export %d configs</string>
|
||||
<string name="title_update_config_count">Update %d configs</string>
|
||||
<string name="title_update_asset_count">Updated %d asset files</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
|
||||
@@ -435,6 +440,11 @@
|
||||
<string name="title_configuration_backup_restore">Backup & Restore</string>
|
||||
<string name="title_configuration_backup">Backup config</string>
|
||||
<string name="title_configuration_restore">Restore config</string>
|
||||
<string name="title_profile_storage_cleanup">Repair profile storage</string>
|
||||
<string name="summary_profile_storage_cleanup">Remove unused profile data</string>
|
||||
<string name="message_profile_storage_cleanup">Delete profile data that is no longer used by any group? Selected profiles and data that cannot be safely classified will be kept.</string>
|
||||
<string name="toast_profile_storage_cleanup">Unused profiles removed: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">Profile storage could not be safely classified. Nothing was removed.</string>
|
||||
<string name="title_configuration_share">Share config</string>
|
||||
<string name="title_webdav_config_setting">WebDAV Settings</string>
|
||||
<string name="title_webdav_config_setting_unknown">Please configure WebDAV first.</string>
|
||||
@@ -514,6 +524,7 @@
|
||||
<string name="acc_search">Search</string>
|
||||
<string name="acc_add">Add configuration</string>
|
||||
<string name="acc_more">More options</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">Share subscription link</string>
|
||||
<string name="acc_share_log">Share log</string>
|
||||
<string name="acc_remove">Remove proxy chain member</string>
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_start_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_start"
|
||||
android:shortcutDisabledMessage="@string/acc_start"
|
||||
android:shortcutId="shortcuts_start"
|
||||
android:shortcutLongLabel="@string/toast_services_start"
|
||||
android:shortcutShortLabel="@string/toast_services_start">
|
||||
android:shortcutLongLabel="@string/acc_start"
|
||||
android:shortcutShortLabel="@string/acc_start">
|
||||
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
@@ -45,10 +45,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_stop_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_stop"
|
||||
android:shortcutDisabledMessage="@string/acc_stop"
|
||||
android:shortcutId="shortcuts_stop"
|
||||
android:shortcutLongLabel="@string/toast_services_stop"
|
||||
android:shortcutShortLabel="@string/toast_services_stop">
|
||||
android:shortcutLongLabel="@string/acc_stop"
|
||||
android:shortcutShortLabel="@string/acc_stop">
|
||||
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
@@ -56,4 +56,4 @@
|
||||
android:targetPackage="com.v2ray.ang" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
</shortcuts>
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OrphanProfileCleanerTest {
|
||||
|
||||
@Test
|
||||
fun `reports no work when profile storage is empty`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = emptyList(),
|
||||
indexedServersBySubscription = emptyMap(),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(emptySet<String>(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses raw group indexes when subscription metadata is missing`() {
|
||||
val profiles = listOf(
|
||||
StoredProfileReference("live", "group-a"),
|
||||
StoredProfileReference("orphan", "group-a"),
|
||||
)
|
||||
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = profiles,
|
||||
indexedServersBySubscription = mapOf("group-a" to setOf("live")),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(setOf("orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps profiles whose group index is missing`() {
|
||||
val profiles = listOf(
|
||||
StoredProfileReference("known-orphan", "group-a"),
|
||||
StoredProfileReference("unknown", "missing-group"),
|
||||
)
|
||||
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = profiles,
|
||||
indexedServersBySubscription = mapOf("group-a" to emptySet()),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(setOf("known-orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aborts cleanup when any raw group index is unreadable`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(StoredProfileReference("candidate", "group-a")),
|
||||
indexedServersBySubscription = mapOf(
|
||||
"group-a" to emptySet(),
|
||||
"corrupt-group" to null,
|
||||
),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aborts cleanup when no raw group indexes survive`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(StoredProfileReference("candidate", "group-a")),
|
||||
indexedServersBySubscription = emptyMap(),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps selected and undecodable profiles`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(
|
||||
StoredProfileReference("selected", "group-a"),
|
||||
StoredProfileReference("undecodable", null),
|
||||
StoredProfileReference("orphan", "group-a"),
|
||||
),
|
||||
indexedServersBySubscription = mapOf("group-a" to emptySet()),
|
||||
selectedServer = "selected",
|
||||
)
|
||||
|
||||
assertEquals(setOf("orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizes ungrouped profiles to the default group`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(
|
||||
StoredProfileReference("live", ""),
|
||||
StoredProfileReference("orphan", ""),
|
||||
),
|
||||
indexedServersBySubscription = mapOf(
|
||||
DEFAULT_SUBSCRIPTION_ID to setOf("live"),
|
||||
),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(setOf("orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps a profile indexed by a different group`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(StoredProfileReference("mismatched", "group-a")),
|
||||
indexedServersBySubscription = mapOf(
|
||||
"group-a" to emptySet(),
|
||||
"group-b" to setOf("mismatched"),
|
||||
),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(emptySet<String>(), result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ProfileReplacementTest {
|
||||
|
||||
@Test
|
||||
fun `prefers a full match over a remarks-only match`() {
|
||||
val profiles = linkedMapOf(
|
||||
"remarks" to profile(remarks = "selected", server = "other"),
|
||||
"full" to profile(remarks = "selected", server = "host", port = "443", password = "secret"),
|
||||
)
|
||||
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = "old",
|
||||
selectedProfile = profile(
|
||||
remarks = " selected ",
|
||||
server = "HOST",
|
||||
port = "443",
|
||||
password = "secret",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("full", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses a remarks match when no full match exists`() {
|
||||
val profiles = linkedMapOf(
|
||||
"other" to profile(remarks = "other", server = "host"),
|
||||
"remarks" to profile(remarks = "selected", server = "other"),
|
||||
)
|
||||
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = "old",
|
||||
selectedProfile = profile(remarks = "selected", server = "host"),
|
||||
)
|
||||
|
||||
assertEquals("remarks", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches endpoint and password when remarks are unavailable`() {
|
||||
val profiles = linkedMapOf(
|
||||
"endpoint" to profile(server = "host", port = "443", password = "secret"),
|
||||
"other" to profile(server = "other", port = "443", password = "secret"),
|
||||
)
|
||||
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = "old",
|
||||
selectedProfile = profile(server = "HOST", port = "443", password = "secret"),
|
||||
)
|
||||
|
||||
assertEquals("endpoint", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the first replacement profile`() {
|
||||
val profiles = linkedMapOf(
|
||||
"first" to profile(server = "first"),
|
||||
"second" to profile(server = "second"),
|
||||
)
|
||||
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = "old",
|
||||
selectedProfile = profile(server = "unmatched"),
|
||||
)
|
||||
|
||||
assertEquals("first", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selects the first profile when there is no current selection`() {
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = linkedMapOf(
|
||||
"first" to profile(server = "first"),
|
||||
"second" to profile(server = "second"),
|
||||
),
|
||||
currentSelection = null,
|
||||
selectedProfile = null,
|
||||
)
|
||||
|
||||
assertEquals("first", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps an existing selection when it is outside the replaced group`() {
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = mapOf("candidate" to profile(server = "host")),
|
||||
currentSelection = "other-group",
|
||||
selectedProfile = null,
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removes only unreferenced superseded payloads`() {
|
||||
val result = ProfileReplacement.findRemovablePayloads(
|
||||
replacedServers = listOf("orphan", "selected", "replacement", "cross-group"),
|
||||
replacementServers = setOf("replacement"),
|
||||
protectedServer = "selected",
|
||||
serversReferencedByOtherGroups = setOf("cross-group"),
|
||||
)
|
||||
|
||||
assertEquals(setOf("orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps all payloads when another group index is unreadable`() {
|
||||
val result = ProfileReplacement.findRemovablePayloads(
|
||||
replacedServers = listOf("candidate"),
|
||||
replacementServers = emptySet(),
|
||||
protectedServer = null,
|
||||
serversReferencedByOtherGroups = null,
|
||||
)
|
||||
|
||||
assertEquals(emptySet<String>(), result)
|
||||
}
|
||||
|
||||
private fun profile(
|
||||
remarks: String = "",
|
||||
server: String = "",
|
||||
port: String = "",
|
||||
password: String = "",
|
||||
) = ProfileItem.create(EConfigType.VMESS).apply {
|
||||
this.remarks = remarks
|
||||
this.server = server
|
||||
this.serverPort = port
|
||||
this.password = password
|
||||
}
|
||||
}
|
||||
@@ -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,61 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class RealPingExecutionLimiterTest {
|
||||
|
||||
@Test
|
||||
fun customConfigMeasurementsAreSerializedAcrossWorkers() {
|
||||
runBlocking {
|
||||
val active = AtomicInteger(0)
|
||||
val maxActive = AtomicInteger(0)
|
||||
|
||||
List(8) {
|
||||
async(Dispatchers.Default) {
|
||||
RealPingExecutionLimiter.run(EConfigType.CUSTOM) {
|
||||
val current = active.incrementAndGet()
|
||||
maxActive.accumulateAndGet(current, ::maxOf)
|
||||
Thread.sleep(20)
|
||||
active.decrementAndGet()
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
assertEquals(1, maxActive.get())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generatedConfigMeasurementsRemainConcurrent() {
|
||||
runBlocking {
|
||||
val entered = CountDownLatch(2)
|
||||
val release = CountDownLatch(1)
|
||||
|
||||
val jobs = List(2) {
|
||||
async(Dispatchers.Default) {
|
||||
RealPingExecutionLimiter.run(EConfigType.VMESS) {
|
||||
entered.countDown()
|
||||
release.await(5, TimeUnit.SECONDS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
release.countDown()
|
||||
}
|
||||
jobs.awaitAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user