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