mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 14:39:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9fcb1a30f8 | ||
|
|
d4fc91502c | ||
|
|
75c9194ec0 | ||
|
|
590be2e9ae | ||
|
|
1ee1213563 | ||
|
|
37b42f04a7 | ||
|
|
3c91b51489 | ||
|
|
06a27ec4d4 | ||
|
|
7a07565f72 | ||
|
|
0456f835bc | ||
|
|
e4e3a36e93 | ||
|
|
4b3c8177db | ||
|
|
780d194213 | ||
|
|
c1374a73fb | ||
|
|
49fe45c49e | ||
|
|
36ab5615d9 | ||
|
|
91c2c93a62 | ||
|
|
10932f5764 | ||
|
|
8dcff4238f | ||
|
|
809bdcdb9d | ||
|
|
b348ca792b | ||
|
|
1fa0ccb4f2 | ||
|
|
3033e471cd | ||
|
|
36796fee37 | ||
|
|
2607b5cbff | ||
|
|
9cdbc5cebb | ||
|
|
f976d397e0 | ||
|
|
a16a7390f4 | ||
|
|
606246f36e | ||
|
|
e8a82d9810 | ||
|
|
739e303fc0 | ||
|
|
f63f649477 | ||
|
|
a2be68f7c5 | ||
|
|
68f9e66a76 | ||
|
|
9be547c3b9 | ||
|
|
fd48dcf9e0 | ||
|
|
775546ce40 | ||
|
|
d8b857665c | ||
|
|
17d03bcc46 | ||
|
|
efb16e7979 | ||
|
|
5ac660e395 | ||
|
|
2dcc7781a5 | ||
|
|
2a51e7e767 | ||
|
|
007e167a45 | ||
|
|
9c80ab3d44 | ||
|
|
320fb6a10b | ||
|
|
559a00e66e | ||
|
|
31dfaeed6c | ||
|
|
19b0c9a2c3 | ||
|
|
db0755cf0c | ||
|
|
dc6a7eae93 | ||
|
|
3ae8db972e | ||
|
|
a899d7f444 | ||
|
|
c8f78621bf | ||
|
|
0cf95ac8a3 | ||
|
|
83abd61a7b | ||
|
|
4dc3a20a5c |
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.6.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
@@ -21,6 +21,9 @@ V2rayNG/app/build
|
||||
V2rayNG/build
|
||||
V2rayNG/local.properties
|
||||
|
||||
# Ignore prebuilt native libraries (built from submodules by compile-hevtun.sh / AndroidLibXrayLite)
|
||||
V2rayNG/app/libs
|
||||
|
||||
# Ignore APK and AAR files
|
||||
*.apk
|
||||
*.aar
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# v2rayNG agent guide
|
||||
|
||||
## Scope and precedence
|
||||
|
||||
This file applies to the entire repository. Read the applicable scoped guide before editing any
|
||||
listed path, including when the agent starts at the repository root:
|
||||
|
||||
- Read `V2rayNG/app/src/main/java/com/v2ray/ang/service/AGENTS.md` for
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/service/`,
|
||||
`V2rayNG/app/src/test/java/com/v2ray/ang/service/`, and these paths under
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/`: `core/CoreServiceManager.kt`,
|
||||
`core/LauncherManager.kt`, `root/`, `contracts/ServiceControl.kt`,
|
||||
`contracts/Tun2SocksControl.kt`, `handler/NotificationManager.kt`,
|
||||
`helper/MessageHelper.kt`, and `helper/NotificationHelper.kt`.
|
||||
- Read `V2rayNG/app/src/main/java/com/v2ray/ang/ui/AGENTS.md` for
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/ui/` and
|
||||
`V2rayNG/app/src/test/java/com/v2ray/ang/ui/`.
|
||||
|
||||
Follow both guides in scoped paths. A scoped rule overrides a root rule only when they require
|
||||
incompatible actions; every other root rule remains active.
|
||||
|
||||
## Requirement language
|
||||
|
||||
- `must`, `must not`, `only`, and `never` are mandatory. Only the current user request or linked
|
||||
acceptance criteria can state an exception; agent inference cannot.
|
||||
- `Task scope` is the named files and behavior plus dependency, test, or build edits that a
|
||||
compiler or required validation proves necessary. Every other change is outside scope.
|
||||
- `Changed behavior` includes direct and downstream build-time or runtime effects through callers,
|
||||
consumers, storage, IPC, and resources.
|
||||
- `Supported` versions, ABIs, distributions, and locales are exactly those declared by
|
||||
`V2rayNG/app/build.gradle.kts` and `.github/workflows/build.yml` inputs.
|
||||
- A behavior is `verified` only when its named check passed on branch HEAD. Otherwise list the
|
||||
exact unrun check and blocker, and do not call the behavior verified.
|
||||
|
||||
## Existing code is not a compliance baseline
|
||||
|
||||
- These guides define the target state; they do not claim that this checkout, an older commit, or
|
||||
another worktree complies. Existing code shows current behavior and is not permission to repeat
|
||||
a pattern.
|
||||
- A `touched declaration` is any function, property, class, composable, resource entry, manifest
|
||||
element, or Gradle block containing an added or removed line. Inspect it and every caller or
|
||||
consumer whose contract changes against all applicable rules before editing.
|
||||
- Every new declaration and changed behavior must comply. Inspect a helper or pattern before
|
||||
reusing it. Tests prove only their assertions; names, green builds, and prior approval prove
|
||||
nothing else.
|
||||
- Do not repair unrelated violations. Fix a violation left on a task-changed execution path in the
|
||||
same branch; if that would change behavior outside scope, request approval first. Report any
|
||||
remaining violation that blocks required validation.
|
||||
|
||||
## Agent-guide synchronization and PR boundary
|
||||
|
||||
- `AGENTS.md` files are the only rule source. `CLAUDE.md`, `GEMINI.md`, and
|
||||
`.github/copilot-instructions.md` are routing shims and must only load or point to applicable
|
||||
`AGENTS.md` files.
|
||||
- Feature, fix, and release branches use instruction files from fetched `upstream/master` as their
|
||||
baseline. If `upstream` is absent, run
|
||||
`git remote add upstream https://github.com/2dust/v2rayNG.git`; otherwise require
|
||||
`git remote get-url upstream` to print exactly that URL. Successfully run
|
||||
`git fetch upstream master` before comparison. An agent-instruction branch uses its HEAD and
|
||||
working diff as the proposal.
|
||||
- At task start and after each upstream merge or rebase, synchronize canonical instructions into
|
||||
the worktree without deleting local-only text. Compare them with:
|
||||
|
||||
```sh
|
||||
git diff upstream/master -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
```
|
||||
|
||||
After synchronization, every displayed difference must be inside a local-only block. If a branch
|
||||
predates the files, keep copied versions untracked or unstaged; acquire tracked copies only by
|
||||
merging or rebasing their upstream commit.
|
||||
- User-, machine-, worktree-, and temporary text is local-only. Put it in `AGENTS.md` between
|
||||
visible lines `LOCAL INSTRUCTIONS - START` and `LOCAL INSTRUCTIONS - END`.
|
||||
Unless the task targets agent instructions, treat every pre-existing instruction-file difference
|
||||
as local-only.
|
||||
- Keep local-only text unstaged and uncommitted. Never use `git add .`, `git add -A`, or
|
||||
`git commit -a` in that worktree. Stage task paths explicitly; never delete, restore, or overwrite
|
||||
local instructions to clean the worktree.
|
||||
- Before each commit, push, or PR update outside agent-instruction scope, run both commands. Both
|
||||
must print no paths; otherwise stop and remove the instruction change from the staged or branch
|
||||
diff without altering its working copy.
|
||||
|
||||
```sh
|
||||
git diff --cached --name-only -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
git diff --name-only upstream/master...HEAD -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
```
|
||||
|
||||
- Commit instruction files only for a task explicitly limited to agent instructions. The branch
|
||||
and PR may contain only `AGENTS.md` and necessary routing-shim changes; name agent instructions
|
||||
in the PR title and state that exclusive scope in the body.
|
||||
|
||||
## Project boundaries
|
||||
|
||||
- The Android project is under `V2rayNG/` and uses Kotlin, Gradle Kotlin DSL, Compose, Material 3,
|
||||
coroutines, and AndroidX lifecycle. Read dependency versions from
|
||||
`V2rayNG/gradle/libs.versions.toml`, SDK levels from `V2rayNG/app/build.gradle.kts`, and CI tools
|
||||
from `.github/workflows/build.yml`; never copy those numbers into a guide.
|
||||
- `AngApplication` initializes MMKV, app locales, WorkManager, defaults, and theme state. Persist
|
||||
application data through `MmkvManager` or `SettingsManager`; do not create `SharedPreferences`
|
||||
or another path for data they own.
|
||||
- `core/` owns native configuration and lifecycle, `handler/` data and application operations,
|
||||
`service/` Android services, and `ui/` Compose activities, components, and ViewModels. Put new
|
||||
code in its owner unless a scoped guide names a narrower owner.
|
||||
- Shared lifecycle and configuration code must retain VPN, proxy-only, and root-mode branches.
|
||||
Change one mode only when task scope names it; gate the change and preserve the other modes.
|
||||
- `AndroidLibXrayLite` is native-core source. `V2rayNG/app/libs/libv2ray.aar` and the HEV libraries
|
||||
are generated inputs; do not modify or commit them unless task scope upgrades a native dependency.
|
||||
Such a PR must identify the source revision and verify every app-declared ABI.
|
||||
|
||||
## Build and validation
|
||||
|
||||
Before building a clean checkout, initialize submodules recursively and reproduce the HEV and AAR
|
||||
steps in `.github/workflows/build.yml`. The AAR revision must match the `AndroidLibXrayLite` gitlink
|
||||
from `git ls-tree HEAD AndroidLibXrayLite`. Take build-tool versions from that workflow and
|
||||
`V2rayNG/gradle/libs.versions.toml`.
|
||||
|
||||
Run Gradle from `V2rayNG/`, using `gradlew.bat` in Windows PowerShell or `./gradlew` on POSIX. Use
|
||||
the Play Store debug variant unless task scope names another distribution.
|
||||
|
||||
Apply every matching validation rule:
|
||||
|
||||
- Documentation only: run `git diff --check`; no Gradle task is required.
|
||||
- Kotlin/Java production code: run the test class that asserts each changed behavior, then
|
||||
`:app:testPlaystoreDebugUnitTest` and `:app:compilePlaystoreDebugKotlin`.
|
||||
- Resources, manifest, Gradle, dependencies, or native packaging: run
|
||||
`:app:assemblePlaystoreDebug`.
|
||||
- F-Droid only: replace `Playstore` with `Fdroid`. Run both variants when shared code branches on a
|
||||
flavor, `BuildConfig.DISTRIBUTION`, or a flavor-specific resource or dependency.
|
||||
- Android service lifecycle, framework callback, native interaction, permission, accessibility,
|
||||
focus, or state restoration: also run an emulator or physical-device check.
|
||||
|
||||
Add or update JUnit tests under `V2rayNG/app/src/test/java/` for changed deterministic logic. Report
|
||||
an unavailable required check under the exact label `Not run`, with its command or scenario and
|
||||
reason. A compile or assembled APK does not verify runtime behavior.
|
||||
|
||||
There is no enforced Kotlin formatter. Preserve local indentation and import order. Do not change
|
||||
whitespace outside touched declarations or reorder imports except as the diff requires.
|
||||
|
||||
## Repository-wide coding rules
|
||||
|
||||
- Keep the diff inside task scope; exclude unrelated refactors, upgrades, generated files,
|
||||
translation cleanup, renames, and formatting-only changes.
|
||||
- Use server GUIDs and group IDs for persistence, asynchronous work, and UI state. Never use a
|
||||
list index, adapter position, or paging position as server or group identity.
|
||||
- Run disk, network, package-manager, native, bitmap, and CPU-intensive work off the main thread.
|
||||
Own each asynchronous operation with a named lifecycle or ViewModel scope and cancel it when the
|
||||
owner ends or newer work supersedes it.
|
||||
- Put all visible and accessibility text in Android resources. Update every locale in
|
||||
`androidResources.localeFilters` for each changed key, preserving placeholders, plurals, and
|
||||
formatting tags.
|
||||
- Never rename or delete a persisted, serialized, routing, import, or export field without a
|
||||
backward-compatible migration and a regression test reading the preceding released format.
|
||||
Multi-record writes must complete together or restore/remove partial writes on every failure.
|
||||
- Log recoverable failures through `LogUtil`, including operation, mode/component, non-secret
|
||||
stable ID when available, and exception. Never log credentials, full proxy URLs, private keys,
|
||||
or exported configurations.
|
||||
- Before adding a constant, helper, manager, repository, or state holder, search its owner. Extend
|
||||
an owner that controls the same data or lifecycle. Create a new abstraction only for different
|
||||
ownership or lifetime, and state that distinction in code or the PR.
|
||||
- A platform workaround needs a comment naming its Android/API or vendor boundary, prevented
|
||||
failure, and exact removal condition.
|
||||
|
||||
## Deprecated, experimental, and version-gated APIs
|
||||
|
||||
- Introduce a deprecated API only for a supported-version fallback lacking the replacement, a
|
||||
required deprecated callback/interface member, or a documented platform/vendor defect that
|
||||
blocks acceptance criteria. The defect requires a test or linked upstream issue.
|
||||
- Isolate each permitted deprecated call in one compatibility function or adapter. Comment the
|
||||
reason and an exact removal trigger. Use the stable API wherever available.
|
||||
- Scope `@Suppress("DEPRECATION")` or `@SuppressLint("<IssueId>")` to the containing expression or function.
|
||||
Class scope is allowed only when every member implements the same required deprecated interface;
|
||||
file, package, and module scope is forbidden. Never suppress permission, background-execution,
|
||||
lifecycle, or security requirements.
|
||||
- Guard every API above `minSdk` by SDK or extension version in the same function, or use
|
||||
`@RequiresApi` only when every entry point is guarded. Mark reusable guards
|
||||
`@ChecksSdkIntAtLeast`. Lower versions must use an
|
||||
existing API, return explicit unsupported status, or hide/disable the feature; never crash or
|
||||
report false success.
|
||||
- Add or expand an experimental API only when no stable API meets named acceptance criteria, its
|
||||
dependency is pinned in `libs.versions.toml`, a project-owned stable interface contains it and
|
||||
excludes it from persisted data, IPC payloads, and public shared contracts, a regression test
|
||||
covers it, and a comment names the opt-in marker and exact reevaluation condition.
|
||||
- Put `@OptIn` on the direct function/property, or on a class only when multiple members need it.
|
||||
File and module opt-ins are forbidden. Existing experimental use outside scope does not permit a
|
||||
new call site.
|
||||
- A preview/canary SDK or alpha dependency is allowed in production only when the request or linked
|
||||
issue requires it. Keep a stable path for supported non-preview devices, gate by runtime API or
|
||||
feature availability, and test both paths. After an API guard, opt-in, or lint suppression, run
|
||||
`:app:lintPlaystoreDebug` and also `:app:lintFdroidDebug` when behavior differs by distribution.
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: b21389865e...d0c6c4ae1b.
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 740
|
||||
versionName = "2.3.0"
|
||||
versionCode = 748
|
||||
versionName = "2.3.8"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
@@ -128,6 +128,21 @@ android {
|
||||
compose = true
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters += listOf(
|
||||
"en",
|
||||
"zh-rCN",
|
||||
"zh-rTW",
|
||||
"vi",
|
||||
"ru",
|
||||
"fa",
|
||||
"ar",
|
||||
"bn",
|
||||
"bqi-rIR"
|
||||
)
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
@@ -142,6 +157,7 @@ dependencies {
|
||||
|
||||
// AndroidX Core Libraries
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
// Compose Libraries
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
@@ -153,6 +169,7 @@ dependencies {
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_start_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_start"
|
||||
android:shortcutDisabledMessage="@string/acc_start"
|
||||
android:shortcutId="shortcuts_start"
|
||||
android:shortcutLongLabel="@string/toast_services_start"
|
||||
android:shortcutShortLabel="@string/toast_services_start">
|
||||
android:shortcutLongLabel="@string/acc_start"
|
||||
android:shortcutShortLabel="@string/acc_start">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
@@ -48,10 +48,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_stop_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_stop"
|
||||
android:shortcutDisabledMessage="@string/acc_stop"
|
||||
android:shortcutId="shortcuts_stop"
|
||||
android:shortcutLongLabel="@string/toast_services_stop"
|
||||
android:shortcutShortLabel="@string/toast_services_stop">
|
||||
android:shortcutLongLabel="@string/acc_stop"
|
||||
android:shortcutShortLabel="@string/acc_stop">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
@@ -60,4 +60,4 @@
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
</shortcuts>
|
||||
@@ -52,6 +52,15 @@
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:exported="true"
|
||||
@@ -144,6 +153,7 @@
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.userasset.UserAssetUrlActivity"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
@@ -157,19 +167,19 @@
|
||||
android:name=".ui.shortcut.ScSwitchActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.shortcut.ScStartActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.shortcut.ScStopActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
|
||||
<activity
|
||||
@@ -200,6 +210,9 @@
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.TranslatorsActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".service.CoreVpnService"
|
||||
@@ -208,7 +221,7 @@
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
tools:ignore="VpnServicePolicy">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
@@ -226,7 +239,7 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
@@ -237,7 +250,7 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
@@ -247,7 +260,7 @@
|
||||
android:name=".service.CoreTestService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":tasks">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
@@ -257,7 +270,7 @@
|
||||
android:name=".service.SubscriptionUpdateService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":tasks">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
@@ -266,7 +279,7 @@
|
||||
<receiver
|
||||
android:name=".receiver.WidgetProvider"
|
||||
android:exported="true"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/app_widget_provider" />
|
||||
@@ -294,7 +307,7 @@
|
||||
android:icon="@drawable/ic_stat_name"
|
||||
android:label="@string/app_tile_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
@@ -315,7 +328,7 @@
|
||||
<receiver
|
||||
android:name=".receiver.TaskerReceiver"
|
||||
android:exported="true"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"language": "العربية (Arabic)",
|
||||
"contributors": [
|
||||
{ "name": "@mribrahem", "displayName": "ibrahem Qasim", "url": "https://github.com/mribrahem" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "বাংলা (Bengali)",
|
||||
"contributors": [
|
||||
{ "name": "@codewithtamim", "displayName": "Tamim Hossain", "url": "https://github.com/codewithtamim" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "لۊری بختیاری (Luri Bakhtiari)",
|
||||
"contributors": [
|
||||
{ "name": "@hosseinabaspanah", "displayName": "Hossein Abaspanah", "url": "https://github.com/hosseinabaspanah" },
|
||||
{ "name": "@codewithtamim", "displayName": "Tamim Hossain", "url": "https://github.com/codewithtamim" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "فارسی (Persian)",
|
||||
"contributors": [
|
||||
{ "name": "@themrvx", "displayName": "MRVX", "url": "https://github.com/themrvx" },
|
||||
{ "name": "@skh-web6982", "url": "https://github.com/skh-web6982" },
|
||||
{ "name": "@ptechgithub", "displayName": "Peyman", "url": "https://github.com/ptechgithub" },
|
||||
{ "name": "@pk-web6936" },
|
||||
{ "name": "@alphax-hue3682" },
|
||||
{ "name": "@phoenix6936" },
|
||||
{ "name": "@decorativefamily" },
|
||||
{ "name": "@decorativeman" },
|
||||
{ "name": "@mh292929", "displayName": "MH", "url": "https://github.com/mh292929" },
|
||||
{ "name": "@amir-yazdanmanesh", "displayName": "AmirMohammad Yazdanmanesh", "url": "https://github.com/amir-yazdanmanesh" },
|
||||
{ "name": "@cumoon", "displayName": "C_U_Moon", "url": "https://github.com/cumoon" },
|
||||
{ "name": "@hadi-norouzi", "displayName": "Hadi Norouzi", "url": "https://github.com/hadi-norouzi" },
|
||||
{ "name": "Vahid Farid" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Русский (Russian)",
|
||||
"contributors": [
|
||||
{ "name": "@solokot", "url": "https://github.com/solokot" },
|
||||
{ "name": "@liniya", "url": "https://github.com/liniya" },
|
||||
{ "name": "@eliotcougar", "url": "https://github.com/eliotcougar" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Tiếng Việt (Vietnamese)",
|
||||
"contributors": [
|
||||
{ "name": "@admarty", "url": "https://github.com/admarty" },
|
||||
{ "name": "@user09283", "url": "https://github.com/user09283" },
|
||||
{ "name": "@yuhan6665", "url": "https://github.com/yuhan6665" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "简体中文 (Simplified Chinese)",
|
||||
"contributors": [
|
||||
{ "name": "@yau08", "displayName": "David", "url": "https://github.com/yau08" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "繁體中文 (Traditional Chinese)",
|
||||
"contributors": [
|
||||
{ "name": "@yau08", "displayName": "David", "url": "https://github.com/yau08" },
|
||||
{ "name": "@fubuki0x10de", "url": "https://github.com/fubuki0x10de" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -76,7 +76,11 @@
|
||||
"protocol": "freedom",
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"domainStrategy": "UseIP"
|
||||
"domainStrategy": "UseIP",
|
||||
"happyEyeballs": {
|
||||
"tryDelayMs": 250,
|
||||
"interleave": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"tag": "direct"
|
||||
|
||||
@@ -93,7 +93,11 @@
|
||||
"protocol": "freedom",
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"domainStrategy": "UseIP"
|
||||
"domainStrategy": "UseIP",
|
||||
"happyEyeballs": {
|
||||
"tryDelayMs": 250,
|
||||
"interleave": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"tag": "direct"
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.v2ray.ang
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.compose.ThemeManager
|
||||
|
||||
@@ -19,7 +21,7 @@ class AngApplication : Application() {
|
||||
* @param base The base context.
|
||||
*/
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
super.attachBaseContext(base?.let(ContextCompat::getContextForLanguage))
|
||||
application = this
|
||||
}
|
||||
|
||||
@@ -33,7 +35,9 @@ class AngApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
MMKV.initialize(this)
|
||||
MmkvManager.initialize(this)
|
||||
|
||||
AppLocaleManager.initialize(this)
|
||||
|
||||
// Initialize WorkManager with the custom configuration
|
||||
WorkManager.initialize(this, workManagerConfiguration)
|
||||
|
||||
@@ -56,7 +56,9 @@ object AppConfig {
|
||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||
const val PREF_LANGUAGE = "pref_language"
|
||||
const val PREF_APP_LOCALE_MIGRATED = "pref_app_locale_migrated"
|
||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
||||
const val PREF_DYNAMIC_COLOR = "pref_dynamic_color"
|
||||
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
|
||||
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
|
||||
@@ -166,6 +168,13 @@ object AppConfig {
|
||||
const val WIREGUARD_LOCAL_MTU = "1420"
|
||||
const val LOOPBACK = "127.0.0.1"
|
||||
|
||||
/** Shared defaults for settings shown in the UI and consumed by config generation. */
|
||||
const val DEFAULT_SOCKS_ENABLE_UDP = true
|
||||
const val DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD = "1"
|
||||
const val DEFAULT_VPN_BYPASS_LAN = "1"
|
||||
const val DEFAULT_HEV_TUNNEL_LOGLEVEL = "warn"
|
||||
const val DEFAULT_MUX_XUDP_CONCURRENCY = "8"
|
||||
|
||||
/** Message constants for communication. */
|
||||
const val MSG_REGISTER_CLIENT = 1
|
||||
const val MSG_STATE_RUNNING = 11
|
||||
@@ -178,7 +187,7 @@ object AppConfig {
|
||||
const val MSG_STATE_STOP_SUCCESS = 41
|
||||
const val MSG_STATE_RESTART = 5
|
||||
const val MSG_MEASURE_DELAY = 6
|
||||
const val MSG_MEASURE_DELAY_SUCCESS = 61
|
||||
const val MSG_MEASURE_DELAY_RESULT = 61
|
||||
const val MSG_MEASURE_CONFIG_START = 7
|
||||
const val MSG_MEASURE_CONFIG_CANCEL = 71
|
||||
const val MSG_MEASURE_CONFIG_SUCCESS = 72
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.v2ray.ang.core
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ConfigResult
|
||||
import com.v2ray.ang.dto.CoreConfigContext
|
||||
@@ -33,7 +34,11 @@ object CoreConfigManager {
|
||||
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
|
||||
try {
|
||||
val configContext = CoreConfigContextBuilder.build(context, guid)
|
||||
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
|
||||
?: return ConfigResult(
|
||||
status = false,
|
||||
guid = guid,
|
||||
errorMessage = "Failed to build config context"
|
||||
)
|
||||
if (configContext.isCustom) {
|
||||
return buildV2rayCustomConfig(configContext)
|
||||
}
|
||||
@@ -56,7 +61,11 @@ object CoreConfigManager {
|
||||
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
|
||||
try {
|
||||
val configContext = CoreConfigContextBuilder.build(context, guid)
|
||||
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
|
||||
?: return ConfigResult(
|
||||
status = false,
|
||||
guid = guid,
|
||||
errorMessage = "Failed to build config context"
|
||||
)
|
||||
if (configContext.isCustom) {
|
||||
return buildV2rayCustomConfig(configContext)
|
||||
}
|
||||
@@ -69,7 +78,7 @@ object CoreConfigManager {
|
||||
return ConfigResult(
|
||||
status = false,
|
||||
guid = guid,
|
||||
errorMessage = "Failed to get V2ray config for speedtest: ${e.message ?: e.javaClass.simpleName}"
|
||||
errorMessage = "Failed to get V2ray config: ${e.message ?: e.javaClass.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -80,14 +89,43 @@ object CoreConfigManager {
|
||||
private fun buildV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
|
||||
val context = configContext.context
|
||||
val raw = MmkvManager.decodeServerRaw(configContext.guid)
|
||||
?: return ConfigResult(status = false, guid = configContext.guid, errorMessage = "Custom config is empty")
|
||||
?: return ConfigResult(
|
||||
status = false,
|
||||
guid = configContext.guid,
|
||||
errorMessage = "Failed to build config context, config is empty"
|
||||
)
|
||||
val result = ConfigResult(true, configContext.guid, raw)
|
||||
if (!needTun()) {
|
||||
return result
|
||||
}
|
||||
|
||||
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
|
||||
|
||||
// Inject or remove traffic statistics configuration based on user preference
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) == true) {
|
||||
if (!json.has("stats")) {
|
||||
json.add("stats", JsonObject())
|
||||
}
|
||||
if (!json.has("policy")) {
|
||||
val policyObj = JsonObject()
|
||||
val systemObj = JsonObject()
|
||||
systemObj.addProperty("statsOutboundUplink", true)
|
||||
systemObj.addProperty("statsOutboundDownlink", true)
|
||||
policyObj.add("system", systemObj)
|
||||
json.add("policy", policyObj)
|
||||
}
|
||||
} else {
|
||||
json.remove("stats")
|
||||
// Keep user-defined policy levels, only strip the stats-related system block
|
||||
json.get("policy")?.takeIf { it.isJsonObject }?.asJsonObject?.let { policy ->
|
||||
policy.remove("system")
|
||||
if (policy.entrySet().isEmpty()) {
|
||||
json.remove("policy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!needTun()) {
|
||||
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, configContext.guid, it) } ?: result
|
||||
}
|
||||
|
||||
// Check whether package names need to be replaced with UIDs
|
||||
if (SettingsManager.canUseProcessRouting()) {
|
||||
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||
@@ -479,7 +517,7 @@ object CoreConfigManager {
|
||||
inbound1.listen = AppConfig.LOOPBACK
|
||||
}
|
||||
inbound1.port = socksPort
|
||||
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
|
||||
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
|
||||
if (socksUsername != null && socksPassword != null) {
|
||||
inbound1.settings?.auth = "password"
|
||||
inbound1.settings?.accounts = listOf(
|
||||
@@ -677,7 +715,7 @@ object CoreConfigManager {
|
||||
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
||||
v2rayConfig.stats = null
|
||||
v2rayConfig.policy = null
|
||||
v2rayConfig.policy?.system = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,9 +953,14 @@ object CoreConfigManager {
|
||||
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
|
||||
if (userHosts.isNotNullEmpty()) {
|
||||
val userHostsMap = userHosts?.split(",").orEmpty()
|
||||
.filter { it.isNotEmpty() }
|
||||
.filter { it.contains(":") }
|
||||
.associate { it.split(":").let { (k, v) -> k to v } }
|
||||
.filter { it.isNotBlank() && it.contains(":") }
|
||||
.associate {
|
||||
// Use limit = 2 to split only at the first colon.
|
||||
// This ensures that IPv6 addresses (which contain multiple colons)
|
||||
// are preserved entirely in the second part.
|
||||
val parts = it.split(":", limit = 2)
|
||||
parts[0].trim() to parts[1].trim()
|
||||
}
|
||||
hosts.putAll(userHostsMap)
|
||||
}
|
||||
|
||||
@@ -1015,7 +1058,7 @@ object CoreConfigManager {
|
||||
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
|
||||
*/
|
||||
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "1") {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ object CoreOutboundBuilder {
|
||||
if (muxEnabled) {
|
||||
outbound.mux?.enabled = true
|
||||
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY).orEmpty().toInt()
|
||||
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
|
||||
outbound.mux?.concurrency = -1
|
||||
@@ -396,6 +396,7 @@ object CoreOutboundBuilder {
|
||||
)
|
||||
)
|
||||
}
|
||||
udpMaskList.reverse()
|
||||
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
udp = udpMaskList.toList()
|
||||
)
|
||||
@@ -635,8 +636,7 @@ object CoreOutboundBuilder {
|
||||
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
|
||||
} ?: JsonObject()
|
||||
|
||||
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
|
||||
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
|
||||
fun appendMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
|
||||
val current = finalMaskObj.get(scope)
|
||||
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
|
||||
return
|
||||
@@ -651,8 +651,8 @@ object CoreOutboundBuilder {
|
||||
finalMaskObj.add(scope, newArray)
|
||||
}
|
||||
|
||||
prependMask("tcp", fragmentMask)
|
||||
prependMask("udp", noiseMask)
|
||||
appendMask("tcp", fragmentMask)
|
||||
appendMask("udp", noiseMask)
|
||||
streamSettings.finalmask = finalMaskObj
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
|
||||
@@ -667,7 +667,7 @@ object CoreOutboundBuilder {
|
||||
}
|
||||
|
||||
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "2") {
|
||||
return domain
|
||||
}
|
||||
//Resolve and replace domain
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -11,12 +12,13 @@ import android.os.ParcelFileDescriptor
|
||||
import android.system.OsConstants
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.IDialerService
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.dto.ConnectionTestResult
|
||||
import com.v2ray.ang.dto.OutboundTrafficStat
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.BrowserDialerMode
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
@@ -31,7 +33,6 @@ import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.jvm.Volatile
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
import libv2ray.ProcessFinder
|
||||
@@ -250,7 +251,7 @@ object CoreServiceManager {
|
||||
if (!isRunning()) return false
|
||||
|
||||
return try {
|
||||
val tunFd = tunFdForCore()
|
||||
val tunFd = currentVpnInterface
|
||||
|
||||
isReloading = true
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
|
||||
@@ -270,23 +271,6 @@ object CoreServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tun descriptor to hand to the core on a reload.
|
||||
*
|
||||
* With hev-socks5-tunnel the core is started without a tun and never touches the descriptor.
|
||||
* Otherwise it closes the one it was given when it stops, which would take the VPN interface
|
||||
* down, so it gets a duplicate to close instead.
|
||||
*/
|
||||
private fun tunFdForCore(): ParcelFileDescriptor? {
|
||||
val vpnInterface = currentVpnInterface ?: return null
|
||||
return try {
|
||||
if (SettingsManager.isUsingHevTun()) vpnInterface else vpnInterface.dup()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to duplicate VPN interface", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries and resets all outbound traffic counters in one core call.
|
||||
* Go side format: tag,direction,value;tag,direction,value;
|
||||
@@ -338,30 +322,25 @@ object CoreServiceManager {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
errorStr = e.message?.substringAfter("\":").orEmpty()
|
||||
}
|
||||
if (time == -1L) {
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
errorStr = e.message?.substringAfter("\":").orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
val result = if (time >= 0) {
|
||||
service.getString(R.string.connection_test_available, time)
|
||||
} else {
|
||||
service.getString(R.string.connection_test_error, errorStr)
|
||||
}
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
|
||||
|
||||
// Only fetch IP info if the delay test was successful
|
||||
if (time >= 0) {
|
||||
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
|
||||
}
|
||||
}
|
||||
val endpoint = if (time >= 0) SpeedtestManager.getRemoteIPInfo() else null
|
||||
val result = ConnectionTestResult(
|
||||
delayMillis = time,
|
||||
errorMessage = errorStr,
|
||||
country = endpoint?.country,
|
||||
ipAddress = endpoint?.ipAddress,
|
||||
)
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +362,7 @@ object CoreServiceManager {
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun startup(): Long {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback startup")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -391,14 +371,8 @@ object CoreServiceManager {
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun shutdown(): Long {
|
||||
val serviceControl = serviceControl?.get() ?: return -1
|
||||
return try {
|
||||
serviceControl.stopService()
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||
-1
|
||||
}
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback shutdown")
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,6 +382,7 @@ object CoreServiceManager {
|
||||
* @return Always returns 0.
|
||||
*/
|
||||
override fun onEmitStatus(l: Long, s: String?): Long {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback onEmitStatus $s")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -486,9 +461,20 @@ object CoreServiceManager {
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
LauncherManager.startService(serviceControl.getService())
|
||||
// The UI and daemon run in separate processes, so acknowledge the active
|
||||
// daemon before stopping it instead of relying on possibly stale UI state.
|
||||
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
try {
|
||||
serviceControl.stopService()
|
||||
delay(500L)
|
||||
LauncherManager.startService(serviceControl.getService())
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_DELAY -> {
|
||||
@@ -509,4 +495,4 @@ object CoreServiceManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,18 @@ object LauncherManager {
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
}
|
||||
|
||||
/** Restarts the active daemon without starting a stopped service. */
|
||||
fun restartService(context: Context) {
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_RESTART, "")
|
||||
}
|
||||
|
||||
/** Restarts the active daemon, or delegates to the caller's permission-aware start flow. */
|
||||
fun restartServiceOrStart(context: Context, startIfStopped: () -> Unit) {
|
||||
MessageHelper.sendMsg2ServiceForResult(context, AppConfig.MSG_STATE_RESTART, "") { handled ->
|
||||
if (!handled) startIfStopped()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun startContextService(context: Context) {
|
||||
// Note: isRunning check is removed here to avoid loading Native libraries in the UI process.
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
data class AppInfo(
|
||||
val appName: String,
|
||||
val packageName: String,
|
||||
val appIcon: Drawable,
|
||||
val isSystemApp: Boolean,
|
||||
var isSelected: Int
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/** Locale-neutral result sent from the daemon for presentation by the UI process. */
|
||||
data class ConnectionTestResult(
|
||||
val delayMillis: Long,
|
||||
val errorMessage: String = "",
|
||||
val country: String? = null,
|
||||
val ipAddress: String? = null,
|
||||
) : Serializable
|
||||
@@ -2,6 +2,5 @@ package com.v2ray.ang.dto
|
||||
|
||||
data class LocateTarget(
|
||||
val groupId: String,
|
||||
val groupIndex: Int,
|
||||
val itemPosition: Int
|
||||
)
|
||||
val serverGuid: String,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
data class Contributor(
|
||||
val name: String,
|
||||
val displayName: String? = null,
|
||||
val url: String? = null
|
||||
)
|
||||
|
||||
data class TranslatorsCredit(
|
||||
val language: String,
|
||||
val contributors: List<Contributor>
|
||||
)
|
||||
|
||||
object TranslatorsParser {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
fun parse(json: String): List<TranslatorsCredit> {
|
||||
if (json.isBlank()) return emptyList()
|
||||
|
||||
return try {
|
||||
val type = object : TypeToken<List<TranslatorsCredit>>() {}.type
|
||||
gson.fromJson<List<TranslatorsCredit>>(json, type).orEmpty()
|
||||
.filter { it.language.isNotBlank() && it.contributors.isNotEmpty() }
|
||||
.distinctBy { it.language }
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse translators.json", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import com.v2ray.ang.enums.NetworkType
|
||||
|
||||
@Suppress("PropertyName")
|
||||
data class V2rayNShareItem(
|
||||
// val IndexId: String?,
|
||||
val IndexId: String?,
|
||||
val ConfigType: Int?,
|
||||
// val CoreType: Int?,
|
||||
val ConfigVersion: Int?,
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
||||
fun getTestDelayString(): String {
|
||||
if (testDelayMillis == 0L) {
|
||||
return ""
|
||||
}
|
||||
return testDelayMillis.toString() + "ms"
|
||||
}
|
||||
}
|
||||
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L)
|
||||
@@ -4,5 +4,4 @@ data class ServersCache(
|
||||
val guid: String,
|
||||
val profile: ProfileItem,
|
||||
val testDelayMillis: Long = 0L,
|
||||
val testDelayString: String = "",
|
||||
)
|
||||
@@ -1,20 +1,57 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class Language(val code: String) {
|
||||
AUTO("auto"),
|
||||
ENGLISH("en"),
|
||||
CHINA("zh-rCN"),
|
||||
TRADITIONAL_CHINESE("zh-rTW"),
|
||||
VIETNAMESE("vi"),
|
||||
RUSSIAN("ru"),
|
||||
PERSIAN("fa"),
|
||||
ARABIC("ar"),
|
||||
BANGLA("bn"),
|
||||
BAKHTIARI("bqi-rIR");
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
enum class Language(val code: String, val languageTag: String) {
|
||||
AUTO("auto", ""),
|
||||
ENGLISH("en", "en"),
|
||||
CHINA("zh-rCN", "zh-CN"),
|
||||
TRADITIONAL_CHINESE("zh-rTW", "zh-TW"),
|
||||
VIETNAMESE("vi", "vi"),
|
||||
RUSSIAN("ru", "ru"),
|
||||
PERSIAN("fa", "fa"),
|
||||
ARABIC("ar", "ar"),
|
||||
BANGLA("bn", "bn"),
|
||||
BAKHTIARI("bqi-rIR", "bqi-IR");
|
||||
|
||||
val locale: Locale?
|
||||
get() = languageTag.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag)
|
||||
|
||||
fun toLocaleList(): LocaleListCompat =
|
||||
if (languageTag.isEmpty()) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
} else {
|
||||
LocaleListCompat.forLanguageTags(languageTag)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String): Language {
|
||||
return entries.find { it.code == code } ?: AUTO
|
||||
}
|
||||
|
||||
fun fromLanguageTag(languageTag: String?): Language {
|
||||
if (languageTag.isNullOrBlank()) return AUTO
|
||||
|
||||
val locale = Locale.forLanguageTag(languageTag)
|
||||
return when (locale.language) {
|
||||
"en" -> ENGLISH
|
||||
"zh" -> if (
|
||||
locale.script == "Hant" || locale.country in setOf("TW", "HK", "MO")
|
||||
) {
|
||||
TRADITIONAL_CHINESE
|
||||
} else {
|
||||
CHINA
|
||||
}
|
||||
|
||||
"vi" -> VIETNAMESE
|
||||
"ru" -> RUSSIAN
|
||||
"fa" -> PERSIAN
|
||||
"ar" -> ARABIC
|
||||
"bn" -> BANGLA
|
||||
"bqi" -> BAKHTIARI
|
||||
else -> AUTO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.v2ray.ang.enums
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import com.v2ray.ang.R
|
||||
|
||||
/**
|
||||
* Permission types used in the app, handling API level differences.
|
||||
@@ -28,12 +30,13 @@ enum class PermissionType {
|
||||
/** Return the actual Android permission string */
|
||||
abstract fun getPermission(): String
|
||||
|
||||
/** Return a human-readable label for the permission */
|
||||
fun getLabel(): String {
|
||||
/** Return the string resource for the human-readable permission label. */
|
||||
@StringRes
|
||||
fun getLabelRes(): Int {
|
||||
return when (this) {
|
||||
CAMERA -> "Camera"
|
||||
POST_NOTIFICATIONS -> "Notification"
|
||||
ACCESS_LOCAL_NETWORK -> "Local Network"
|
||||
CAMERA -> R.string.permission_camera
|
||||
POST_NOTIFICATIONS -> R.string.permission_notification
|
||||
ACCESS_LOCAL_NETWORK -> R.string.permission_local_network
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,5 @@ enum class RoutingType(val fileName: String) {
|
||||
BLACK("custom_routing_black"),
|
||||
GLOBAL("custom_routing_global"),
|
||||
WHITE_IRAN("custom_routing_white_iran"),
|
||||
WHITE_RUSSIA("custom_routing_white_russia");
|
||||
|
||||
companion object {
|
||||
fun fromIndex(index: Int): RoutingType {
|
||||
return when (index) {
|
||||
0 -> WHITE
|
||||
1 -> BLACK
|
||||
2 -> GLOBAL
|
||||
3 -> WHITE_IRAN
|
||||
4 -> WHITE_RUSSIA
|
||||
else -> WHITE
|
||||
}
|
||||
}
|
||||
}
|
||||
WHITE_RUSSIA("custom_routing_white_russia")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.v2ray.ang.enums.EConfigType
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
const val THRESHOLD = 1000L
|
||||
const val DIVISOR = 1024.0
|
||||
@@ -76,3 +77,19 @@ fun EConfigType.isGroupType(): Boolean {
|
||||
fun EConfigType.isComplexType(): Boolean {
|
||||
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for delay with Int milliseconds using Duration to avoid legacy Long overload warning.
|
||||
*/
|
||||
suspend fun delay(millis: Int) {
|
||||
kotlinx.coroutines.delay(millis.toLong().milliseconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for delay with Long milliseconds using Duration to avoid legacy Long overload warning.
|
||||
*/
|
||||
suspend fun delay(millis: Long) {
|
||||
kotlinx.coroutines.delay(millis.milliseconds)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,46 @@ package com.v2ray.ang.fmt
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.V2rayNShareItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object V2rayNFmt : FmtBase() {
|
||||
fun parse(str: String): ProfileItem? {
|
||||
try {
|
||||
val jsonBase64Payload = str.substringAfterLast('/')
|
||||
val jsonPayload = Utils.decode(jsonBase64Payload)
|
||||
val v2rayNShareItem = JsonUtil.fromJson(jsonPayload, V2rayNShareItem::class.java)
|
||||
return v2rayNShareItem?.toProfileItem()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN format", e)
|
||||
fun parse(list: List<String>, subId: String): List<ProfileItem> {
|
||||
val idMap = mutableMapOf<String, V2rayNShareItem>()
|
||||
list.mapNotNull(::parseShareItem).forEach { item ->
|
||||
idMap.putIfAbsent(item.IndexId.orEmpty(), item)
|
||||
}
|
||||
return null
|
||||
|
||||
return idMap.values.map { item ->
|
||||
item.toProfileItem().apply {
|
||||
val proto = item.ProtoExtraObj
|
||||
policyGroupSubscriptionId = if (proto?.SubChildItems == "self") {
|
||||
subId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
proto?.ChildItems?.takeIf { it.isNotNullEmpty() }?.let { ids ->
|
||||
val remarks = ids.split(",")
|
||||
.mapNotNull { idMap[it]?.Remarks }
|
||||
.filter { it.isNotNullEmpty() }
|
||||
|
||||
if (remarks.isNotEmpty()) {
|
||||
when (item.ConfigType) {
|
||||
101 -> policyGroupFilter = remarks.joinToString("|", "^(", ")$") { Regex.escape(it) }
|
||||
102 -> proxyChainProfiles = remarks.joinToString(",")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseShareItem(str: String): V2rayNShareItem? = try {
|
||||
JsonUtil.fromJson(Utils.decode(str.substringAfterLast('/')), V2rayNShareItem::class.java)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN share item", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreConfigManager
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
@@ -31,6 +30,11 @@ import java.net.URI
|
||||
|
||||
object AngConfigManager {
|
||||
|
||||
private data class ParsedProfile(
|
||||
val profile: ProfileItem,
|
||||
val rawConfig: String? = null,
|
||||
)
|
||||
|
||||
// Parser mapping for different config types (lazy initialized)
|
||||
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
|
||||
mapOf(
|
||||
@@ -44,7 +48,6 @@ object AngConfigManager {
|
||||
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
|
||||
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
|
||||
AppConfig.HY2 to Hysteria2Fmt::parse,
|
||||
AppConfig.V2RAYNFMTS to V2rayNFmt::parse
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,23 +180,28 @@ object AngConfigManager {
|
||||
* @return A pair containing the number of configurations and subscriptions imported.
|
||||
*/
|
||||
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
return try {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
|
||||
return count to countSub
|
||||
count to countSub
|
||||
} catch (e: ProfileStorageException) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to store imported profiles", e)
|
||||
0 to 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,34 +244,40 @@ object AngConfigManager {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
// Find the currently selected server that belongs to the same subscription before replacement.
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
|
||||
// Parse all configs first (no I/O during parsing)
|
||||
val configs = mutableListOf<ProfileItem>()
|
||||
val v2raynLines = mutableListOf<String>()
|
||||
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.reversed()
|
||||
.forEach {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
if (it.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)) {
|
||||
v2raynLines.add(it)
|
||||
} else {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch save all parsed configs (only one serverList read/write)
|
||||
if (configs.isNotEmpty()) {
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val keyToProfile = batchSaveConfigs(configs, subid)
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
val v2raynConfigs = V2rayNFmt.parse(v2raynLines, subid)
|
||||
val allConfigs = v2raynConfigs + configs
|
||||
|
||||
if (allConfigs.isNotEmpty()) {
|
||||
commitProfiles(
|
||||
configs = allConfigs.map(::ParsedProfile),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
return configs.size
|
||||
return allConfigs.size
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
@@ -271,112 +285,32 @@ object AngConfigManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch save configurations to reduce serverList read/write operations.
|
||||
* Reads serverList once, saves all configs, then writes serverList once.
|
||||
* Commits parsed profiles before removing the profiles they replace.
|
||||
*
|
||||
* @param configs The list of ProfileItem to save.
|
||||
* @param configs The parsed profiles to save.
|
||||
* @param subid The subscription ID.
|
||||
* @return Map of generated keys to their corresponding ProfileItem.
|
||||
* @param append Whether to append to the existing server list.
|
||||
*/
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
private fun commitProfiles(
|
||||
configs: List<ParsedProfile>,
|
||||
subid: String,
|
||||
append: Boolean,
|
||||
) {
|
||||
val keyToProfile = linkedMapOf<String, ProfileItem>()
|
||||
val rawConfigs = mutableMapOf<String, String>()
|
||||
|
||||
// Read serverList once
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
|
||||
configs.forEach { config ->
|
||||
configs.forEach { parsed ->
|
||||
val key = Utils.getUuid()
|
||||
// Save profile directly without updating serverList
|
||||
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
}
|
||||
keyToProfile[key] = config
|
||||
keyToProfile[key] = parsed.profile
|
||||
parsed.rawConfig?.let { raw -> rawConfigs[key] = raw }
|
||||
}
|
||||
|
||||
// Write serverList once
|
||||
MmkvManager.encodeServerList(serverList, subid)
|
||||
return keyToProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a matched profile key from the given key-profile map using multi-level matching.
|
||||
* Matching priority (from highest to lowest):
|
||||
* 1. Exact match: server + port + password
|
||||
* 2. Match by remarks (exact match)
|
||||
* 3. Match by server + port
|
||||
* 4. Match by server only
|
||||
*
|
||||
* @param keyToProfile Map of server keys to their ProfileItem
|
||||
* @param target Target profile to match
|
||||
* @return Matched key or null
|
||||
*/
|
||||
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
|
||||
if (keyToProfile.isEmpty()) return null
|
||||
if (target == null) return null
|
||||
|
||||
// Level 0: Full match (remarks + server + port + password)
|
||||
if (target.remarks.isNotBlank()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks) &&
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 1: Match by remarks
|
||||
if (target.remarks.isNotBlank()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 2: Exact match (server + port + password)
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 3: Match by server + port
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 4: Match by server only
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// If old selected node cannot be matched, fall back to the first imported config.
|
||||
return keyToProfile.keys.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
|
||||
*/
|
||||
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
|
||||
if (subid.isBlank() || append) return null
|
||||
|
||||
return MmkvManager.getSelectServer()
|
||||
.takeIf { it?.isNotBlank() == true }
|
||||
?.let { MmkvManager.decodeServerConfig(it) }
|
||||
?.takeIf { it.subscriptionId == subid }
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive trimmed string comparison.
|
||||
*
|
||||
* @param left First string
|
||||
* @param right Second string
|
||||
* @return True if both are non-empty and equal (case-insensitive, trimmed)
|
||||
*/
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
MmkvManager.saveServerProfiles(
|
||||
profiles = keyToProfile,
|
||||
rawConfigs = rawConfigs,
|
||||
subscriptionId = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,56 +334,54 @@ object AngConfigManager {
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
var count = 0
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
val configs = serverList.reversed().map { srv ->
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv))
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
keyToProfile[key] = config
|
||||
count += 1
|
||||
ParsedProfile(
|
||||
profile = config,
|
||||
rawConfig = JsonUtil.toJsonPretty(srv) ?: "",
|
||||
)
|
||||
}
|
||||
if (count > 0) {
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
return count
|
||||
commitProfiles(configs, subid, append)
|
||||
return configs.size
|
||||
}
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// For compatibility
|
||||
val config = CustomFmt.parse(server) ?: return 0
|
||||
val config = CustomFmt.parse(server)
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
commitProfiles(
|
||||
configs = listOf(ParsedProfile(config, server)),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
return 1
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
}
|
||||
return 0
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
try {
|
||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||
val config = WireguardFmt.parseWireguardConfFile(server)
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
commitProfiles(
|
||||
configs = listOf(ParsedProfile(config, server)),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
return 1
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
@@ -496,12 +428,6 @@ object AngConfigManager {
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
|
||||
if (str.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)
|
||||
&& config.policyGroupSubscriptionId == "self"
|
||||
) {
|
||||
config.policyGroupSubscriptionId = subid
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.app.LocaleManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.LocaleManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.enums.Language
|
||||
|
||||
/**
|
||||
* Keeps the legacy in-app language preference synchronized with Android's per-app locale APIs.
|
||||
*/
|
||||
object AppLocaleManager {
|
||||
|
||||
/**
|
||||
* Migrates the existing MMKV language preference and restores it before the first activity.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Api33.prepareMigration(context)
|
||||
} else {
|
||||
initializeCompat(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the one-time handoff after AppCompat has attached the activity context.
|
||||
*/
|
||||
fun onActivityCreated(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Api33.completeMigration(context)
|
||||
} else {
|
||||
syncLegacyPreference(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a language selected in the app and lets AppCompat recreate the current activity.
|
||||
*/
|
||||
fun setApplicationLanguage(languageCode: String) {
|
||||
val language = Language.fromCode(languageCode)
|
||||
persistLegacyPreference(language)
|
||||
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a context suitable for resource access outside AppCompatActivity.
|
||||
*/
|
||||
fun localizedContext(context: Context): Context {
|
||||
val localizedContext = ContextCompat.getContextForLanguage(context)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || localizedContext !== context) {
|
||||
return localizedContext
|
||||
}
|
||||
|
||||
// During the one-time upgrade from the old picker, AppCompat has not written its locale
|
||||
// record yet. Use the still-current MMKV value for non-activity contexts on that launch.
|
||||
val language = storedLanguage()
|
||||
language.locale ?: return context
|
||||
val configuration = Configuration(context.resources.configuration)
|
||||
ConfigurationCompat.setLocales(configuration, language.toLocaleList())
|
||||
return context.createConfigurationContext(configuration)
|
||||
}
|
||||
|
||||
private fun initializeCompat(context: Context) {
|
||||
val storedLocales = LocaleManagerCompat.getApplicationLocales(context)
|
||||
if (storedLocales.isEmpty) {
|
||||
val legacyLanguage = storedLanguage()
|
||||
if (legacyLanguage != Language.AUTO) {
|
||||
AppCompatDelegate.setApplicationLocales(legacyLanguage.toLocaleList())
|
||||
}
|
||||
} else {
|
||||
syncLegacyPreference(storedLocales.get(0)?.toLanguageTag())
|
||||
}
|
||||
}
|
||||
|
||||
private fun storedLanguage(): Language = Language.fromCode(
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
|
||||
)
|
||||
|
||||
private fun syncLegacyPreference(languageTag: String?) {
|
||||
persistLegacyPreference(Language.fromLanguageTag(languageTag))
|
||||
}
|
||||
|
||||
private fun persistLegacyPreference(language: Language) {
|
||||
val current = MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE)
|
||||
if (current != language.code) {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_LANGUAGE, language.code)
|
||||
SettingsChangeManager.notifySettingChanged(AppConfig.PREF_LANGUAGE)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
private object Api33 {
|
||||
fun prepareMigration(context: Context) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val localeManager = context.getSystemService(LocaleManager::class.java)
|
||||
val applicationLocales = localeManager.applicationLocales
|
||||
if (!applicationLocales.isEmpty) {
|
||||
syncLegacyPreference(applicationLocales.get(0)?.toLanguageTag())
|
||||
return
|
||||
}
|
||||
|
||||
val legacyLanguage = storedLanguage()
|
||||
if (legacyLanguage != Language.AUTO) {
|
||||
// Make application and service contexts correct even before the first activity.
|
||||
localeManager.applicationLocales = android.os.LocaleList(
|
||||
requireNotNull(legacyLanguage.locale)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun completeMigration(context: Context) {
|
||||
val localeManager = context.getSystemService(LocaleManager::class.java)
|
||||
if (!MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
|
||||
val frameworkLanguage = Language.fromLanguageTag(
|
||||
localeManager.applicationLocales.get(0)?.toLanguageTag()
|
||||
)
|
||||
val language = frameworkLanguage.takeUnless { it == Language.AUTO }
|
||||
?: storedLanguage()
|
||||
|
||||
// Android's migration guidance requires this one-time AppCompat handoff after
|
||||
// Activity.onCreate(), including on Android 13 and newer.
|
||||
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
|
||||
persistLegacyPreference(language)
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_APP_LOCALE_MIGRATED, true)
|
||||
return
|
||||
}
|
||||
|
||||
syncLegacyPreference(localeManager.applicationLocales.get(0)?.toLanguageTag())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -7,9 +9,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.tencent.mmkv.MMKVHandler
|
||||
import com.tencent.mmkv.MMKVLogLevel
|
||||
import com.tencent.mmkv.MMKVRecoverStrategic
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||
import com.v2ray.ang.AppConfig.TAG
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
@@ -24,6 +31,8 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
|
||||
internal class ProfileStorageException(message: String) : IllegalStateException(message)
|
||||
|
||||
object MmkvManager {
|
||||
|
||||
//region private
|
||||
@@ -41,6 +50,24 @@ object MmkvManager {
|
||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||
|
||||
private val recoveryHandler = object : MMKVHandler {
|
||||
override fun onMMKVCRCCheckFail(mmapID: String) =
|
||||
recoverFromStorageError(mmapID, "CRC check")
|
||||
|
||||
override fun onMMKVFileLengthError(mmapID: String) =
|
||||
recoverFromStorageError(mmapID, "file length check")
|
||||
|
||||
override fun wantLogRedirecting(): Boolean = false
|
||||
|
||||
override fun mmkvLog(
|
||||
level: MMKVLogLevel,
|
||||
file: String,
|
||||
line: Int,
|
||||
function: String,
|
||||
message: String
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||
@@ -49,8 +76,83 @@ object MmkvManager {
|
||||
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
|
||||
|
||||
private inline fun <T> withProfileIndexLock(block: () -> T): T {
|
||||
return synchronized(mainStorage) {
|
||||
mainStorage.lock()
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
mainStorage.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeProfilePayloads(guids: Collection<String>) {
|
||||
if (guids.isEmpty()) return
|
||||
val keys = guids.toTypedArray()
|
||||
profileFullStorage.removeValuesForKeys(keys)
|
||||
serverAffStorage.removeValuesForKeys(keys)
|
||||
serverRawStorage.removeValuesForKeys(keys)
|
||||
}
|
||||
|
||||
private fun requireStorageWrite(success: Boolean, message: String) {
|
||||
if (!success) throw ProfileStorageException(message)
|
||||
}
|
||||
|
||||
private fun persistServerList(serverList: List<String>, subscriptionId: String): Boolean {
|
||||
return mainStorage.encode(serverListKey(subscriptionId), JsonUtil.toJson(serverList))
|
||||
}
|
||||
|
||||
private fun serverListKey(subscriptionId: String): String {
|
||||
return "$KEY_SUB_SERVER_PREFIX${getSubscriptionId(subscriptionId)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every server referenced outside the target group, or null if the raw indexes
|
||||
* cannot provide a complete view.
|
||||
*/
|
||||
private fun decodeServersReferencedByOtherGroups(subscriptionId: String): Set<String>? {
|
||||
val targetKey = serverListKey(subscriptionId)
|
||||
val keys = mainStorage.allKeys() ?: return null
|
||||
if (targetKey !in keys) return null
|
||||
|
||||
val referencedServers = mutableSetOf<String>()
|
||||
for (key in keys) {
|
||||
if (!key.startsWith(KEY_SUB_SERVER_PREFIX) || key == targetKey) continue
|
||||
|
||||
val json = mainStorage.decodeString(key)
|
||||
if (json.isNullOrBlank()) return null
|
||||
val serverIds = JsonUtil.fromJsonSafe(json, Array<String>::class.java) ?: return null
|
||||
referencedServers.addAll(serverIds)
|
||||
}
|
||||
return referencedServers
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
/**
|
||||
* Initializes MMKV with best-effort recovery so a damaged store is not silently discarded.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
val logLevel = if (BuildConfig.DEBUG) {
|
||||
MMKVLogLevel.LevelDebug
|
||||
} else {
|
||||
MMKVLogLevel.LevelInfo
|
||||
}
|
||||
MMKV.initialize(
|
||||
context,
|
||||
context.filesDir.resolve("mmkv").absolutePath,
|
||||
null,
|
||||
logLevel,
|
||||
recoveryHandler
|
||||
)
|
||||
}
|
||||
|
||||
private fun recoverFromStorageError(mmapID: String, error: String): MMKVRecoverStrategic {
|
||||
Log.e(TAG, "MMKV $error failed for $mmapID; attempting data recovery")
|
||||
return MMKVRecoverStrategic.OnErrorRecover
|
||||
}
|
||||
|
||||
//region Server
|
||||
|
||||
/**
|
||||
@@ -79,7 +181,9 @@ object MmkvManager {
|
||||
* @param guid The server GUID.
|
||||
*/
|
||||
fun setSelectServer(guid: String) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
withProfileIndexLock {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,9 +194,9 @@ object MmkvManager {
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
||||
withProfileIndexLock {
|
||||
persistServerList(serverList, subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,9 +209,7 @@ object MmkvManager {
|
||||
* @return The list of server GUIDs.
|
||||
*/
|
||||
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
val json = mainStorage.decodeString(key)
|
||||
val json = mainStorage.decodeString(serverListKey(subscriptionId))
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
@@ -166,17 +268,28 @@ object MmkvManager {
|
||||
*/
|
||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||
val key = guid.ifBlank { Utils.getUuid() }
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||
withProfileIndexLock {
|
||||
requireStorageWrite(
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config)),
|
||||
"Failed to save profile payload",
|
||||
)
|
||||
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
encodeServerList(serverList, subId)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
requireStorageWrite(
|
||||
persistServerList(serverList, subId),
|
||||
"Failed to publish profile index",
|
||||
)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
requireStorageWrite(
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key),
|
||||
"Failed to update selected profile",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,13 +297,88 @@ object MmkvManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the server configuration directly without updating serverList.
|
||||
* Saves a profile batch before publishing its group index and removing replaced payloads.
|
||||
*
|
||||
* @param key The server GUID.
|
||||
* @param configJson The server configuration JSON string.
|
||||
* @param profiles Generated GUIDs and parsed profiles, in insertion order.
|
||||
* @param rawConfigs Optional raw configuration payloads keyed by profile GUID.
|
||||
* @param subscriptionId The destination subscription ID.
|
||||
* @param append Whether to append to the existing group index.
|
||||
*/
|
||||
fun encodeProfileDirect(key: String, configJson: String) {
|
||||
profileFullStorage.encode(key, configJson)
|
||||
internal fun saveServerProfiles(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
rawConfigs: Map<String, String>,
|
||||
subscriptionId: String,
|
||||
append: Boolean,
|
||||
) {
|
||||
if (profiles.isEmpty()) return
|
||||
|
||||
withProfileIndexLock {
|
||||
val replacedServers = if (append) {
|
||||
emptyList()
|
||||
} else {
|
||||
decodeServerList(subscriptionId).toList()
|
||||
}
|
||||
val previousSelection = getSelectServer()
|
||||
val selectedProfile = if (!append &&
|
||||
previousSelection != null &&
|
||||
previousSelection in replacedServers
|
||||
) {
|
||||
decodeServerConfig(previousSelection)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val replacementSelection = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = previousSelection,
|
||||
selectedProfile = selectedProfile,
|
||||
)
|
||||
|
||||
profiles.forEach { (guid, profile) ->
|
||||
requireStorageWrite(
|
||||
profileFullStorage.encode(guid, JsonUtil.toJson(profile)),
|
||||
"Failed to save profile payload",
|
||||
)
|
||||
rawConfigs[guid]?.let { raw ->
|
||||
requireStorageWrite(
|
||||
serverRawStorage.encode(guid, raw),
|
||||
"Failed to save raw profile payload",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val serverList = if (append) {
|
||||
decodeServerList(subscriptionId)
|
||||
} else {
|
||||
mutableListOf()
|
||||
}
|
||||
val indexedServers = serverList.toHashSet()
|
||||
profiles.keys.forEach { guid ->
|
||||
if (indexedServers.add(guid)) {
|
||||
serverList.add(0, guid)
|
||||
}
|
||||
}
|
||||
requireStorageWrite(
|
||||
persistServerList(serverList, subscriptionId),
|
||||
"Failed to publish profile index",
|
||||
)
|
||||
replacementSelection?.let { guid ->
|
||||
requireStorageWrite(
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid),
|
||||
"Failed to update selected profile",
|
||||
)
|
||||
}
|
||||
if (replacedServers.isEmpty()) return@withProfileIndexLock
|
||||
|
||||
val protectedServer = replacementSelection ?: previousSelection
|
||||
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
|
||||
val removablePayloads = ProfileReplacement.findRemovablePayloads(
|
||||
replacedServers = replacedServers,
|
||||
replacementServers = profiles.keys,
|
||||
protectedServer = protectedServer,
|
||||
serversReferencedByOtherGroups = referencedByOtherGroups,
|
||||
)
|
||||
removeProfilePayloads(removablePayloads)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,6 +566,56 @@ object MmkvManager {
|
||||
return serverRawStorage.decodeString(guid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes profile payloads that are provably absent from their raw SUB_SERVERS_* index.
|
||||
*
|
||||
* SUB_IDS and SUB are intentionally ignored: either store can be missing after MMKV
|
||||
* recovery while the group indexes still identify live profiles. If any group index or
|
||||
* profile payload needed for a decision is unreadable, that data is preserved.
|
||||
*
|
||||
* @return The number of profile payloads removed, or null if cleanup could not run safely.
|
||||
*/
|
||||
internal fun removeOrphanedServerProfiles(): Int? = synchronized(mainStorage) {
|
||||
mainStorage.lock()
|
||||
try {
|
||||
val indexedServersBySubscription = mainStorage.allKeys().orEmpty()
|
||||
.asSequence()
|
||||
.filter { key -> key.startsWith(KEY_SUB_SERVER_PREFIX) }
|
||||
.associate { key ->
|
||||
val subscriptionId = key.removePrefix(KEY_SUB_SERVER_PREFIX)
|
||||
val json = mainStorage.decodeString(key)
|
||||
val serverIds = if (json.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toSet()
|
||||
}
|
||||
subscriptionId to serverIds
|
||||
}
|
||||
|
||||
val profiles = profileFullStorage.allKeys().orEmpty().map { guid ->
|
||||
StoredProfileReference(
|
||||
guid = guid,
|
||||
subscriptionId = decodeServerConfig(guid)?.subscriptionId,
|
||||
)
|
||||
}
|
||||
val orphans = OrphanProfileCleaner.findOrphans(
|
||||
profiles = profiles,
|
||||
indexedServersBySubscription = indexedServersBySubscription,
|
||||
selectedServer = getSelectServer(),
|
||||
) ?: return@synchronized null
|
||||
|
||||
if (orphans.isNotEmpty()) {
|
||||
val keys = orphans.toTypedArray()
|
||||
profileFullStorage.removeValuesForKeys(keys)
|
||||
serverAffStorage.removeValuesForKeys(keys)
|
||||
serverRawStorage.removeValuesForKeys(keys)
|
||||
}
|
||||
orphans.size
|
||||
} finally {
|
||||
mainStorage.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Subscriptions
|
||||
@@ -480,7 +718,8 @@ object MmkvManager {
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
|
||||
// Keep the first occurrence so a damaged index cannot produce duplicate Compose keys.
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.distinct()?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.toSpeedString
|
||||
import com.v2ray.ang.ui.main.MainActivity
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
@@ -248,7 +248,8 @@ object NotificationManager {
|
||||
}
|
||||
}
|
||||
|
||||
stat.tag.startsWith(AppConfig.TAG_PROXY) -> {
|
||||
// Accumulate stats for all proxy outbounds (including custom subscription tags)
|
||||
stat.tag != AppConfig.TAG_BLOCKED -> {
|
||||
when (stat.direction) {
|
||||
AppConfig.UPLINK -> proxyUplink += stat.value
|
||||
AppConfig.DOWNLINK -> proxyDownlink += stat.value
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
|
||||
internal data class StoredProfileReference(
|
||||
val guid: String,
|
||||
val subscriptionId: String?,
|
||||
)
|
||||
|
||||
internal object OrphanProfileCleaner {
|
||||
|
||||
/**
|
||||
* Finds profile payloads that are provably unreachable from the raw group indexes.
|
||||
*
|
||||
* A null subscription ID means that the profile payload could not be decoded and is
|
||||
* preserved. A null server set means that a group index could not be decoded, in which
|
||||
* case the entire classification returns null and no cleanup should run.
|
||||
* Subscription metadata is deliberately not an input because SUB and SUB_IDS can be
|
||||
* missing while the raw SUB_SERVERS_* indexes remain intact.
|
||||
*/
|
||||
fun findOrphans(
|
||||
profiles: Collection<StoredProfileReference>,
|
||||
indexedServersBySubscription: Map<String, Set<String>?>,
|
||||
selectedServer: String?,
|
||||
): Set<String>? {
|
||||
if (profiles.isEmpty()) return emptySet()
|
||||
|
||||
if (indexedServersBySubscription.isEmpty() ||
|
||||
indexedServersBySubscription.values.any { it == null }
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val indexedServers = indexedServersBySubscription.values
|
||||
.filterNotNull()
|
||||
.flatten()
|
||||
.toSet()
|
||||
|
||||
return profiles.mapNotNullTo(linkedSetOf()) { profile ->
|
||||
if (profile.guid == selectedServer || profile.guid in indexedServers) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
val subscriptionId = profile.subscriptionId ?: return@mapNotNullTo null
|
||||
val groupId = subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||
if (!indexedServersBySubscription.containsKey(groupId)) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
profile.guid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
internal object ProfileReplacement {
|
||||
|
||||
/**
|
||||
* Finds the profile that should become selected after publishing a replacement batch.
|
||||
* The first profile becomes selected when the store has no current selection.
|
||||
*/
|
||||
fun findSelectedReplacement(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
currentSelection: String?,
|
||||
selectedProfile: ProfileItem?,
|
||||
): String? {
|
||||
if (profiles.isEmpty()) return null
|
||||
if (currentSelection.isNullOrBlank()) return profiles.keys.first()
|
||||
if (selectedProfile == null) return null
|
||||
|
||||
if (selectedProfile.remarks.isNotBlank()) {
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks) &&
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
return profiles.keys.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds replaced payloads that are safe to remove.
|
||||
*
|
||||
* A null cross-group reference set means that at least one raw group index could not
|
||||
* be read. In that case deletion fails closed.
|
||||
*/
|
||||
fun findRemovablePayloads(
|
||||
replacedServers: Collection<String>,
|
||||
replacementServers: Set<String>,
|
||||
protectedServer: String?,
|
||||
serversReferencedByOtherGroups: Set<String>?,
|
||||
): Set<String> {
|
||||
if (serversReferencedByOtherGroups == null) return emptySet()
|
||||
|
||||
return replacedServers.filterTo(linkedSetOf()) { guid ->
|
||||
guid != protectedServer &&
|
||||
guid !in replacementServers &&
|
||||
guid !in serversReferencedByOtherGroups
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ object SettingsChangeManager {
|
||||
AppConfig.PREF_GROUP_ALL_DISPLAY,
|
||||
AppConfig.PREF_LANGUAGE,
|
||||
AppConfig.PREF_UI_MODE_NIGHT,
|
||||
AppConfig.PREF_DYNAMIC_COLOR,
|
||||
AppConfig.PREF_IS_BOOTED,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.Language
|
||||
import com.v2ray.ang.enums.RoutingType
|
||||
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
||||
import com.v2ray.ang.extension.moveItem
|
||||
@@ -31,7 +30,6 @@ import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
object SettingsManager {
|
||||
@@ -62,12 +60,11 @@ object SettingsManager {
|
||||
/**
|
||||
* Get preset routing rulesets.
|
||||
* @param context The application context.
|
||||
* @param index The index of the routing type.
|
||||
* @param type The routing preset type.
|
||||
* @return A mutable list of RulesetItem.
|
||||
*/
|
||||
private fun getPresetRoutingRulesets(context: Context, index: Int = 0): MutableList<RulesetItem>? {
|
||||
val fileName = RoutingType.fromIndex(index).fileName
|
||||
val assets = Utils.readTextFromAssets(context, fileName)
|
||||
private fun getPresetRoutingRulesets(context: Context, type: RoutingType = RoutingType.WHITE): MutableList<RulesetItem>? {
|
||||
val assets = Utils.readTextFromAssets(context, type.fileName)
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
return null
|
||||
}
|
||||
@@ -78,10 +75,10 @@ object SettingsManager {
|
||||
/**
|
||||
* Reset routing rulesets from presets.
|
||||
* @param context The application context.
|
||||
* @param index The index of the routing type.
|
||||
* @param type The routing preset type.
|
||||
*/
|
||||
fun resetRoutingRulesetsFromPresets(context: Context, index: Int) {
|
||||
val rulesetList = getPresetRoutingRulesets(context, index) ?: return
|
||||
fun resetRoutingRulesetsFromPresets(context: Context, type: RoutingType) {
|
||||
val rulesetList = getPresetRoutingRulesets(context, type) ?: return
|
||||
resetRoutingRulesetsCommon(rulesetList)
|
||||
}
|
||||
|
||||
@@ -179,7 +176,7 @@ object SettingsManager {
|
||||
* @return True if bypassing LAN, false otherwise.
|
||||
*/
|
||||
fun routingRulesetsBypassLan(): Boolean {
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
|
||||
if (vpnBypassLan == "1") {
|
||||
return true
|
||||
} else if (vpnBypassLan == "2") {
|
||||
@@ -389,29 +386,6 @@ object SettingsManager {
|
||||
return value.coerceIn(1, 128)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locale.
|
||||
* @return The locale.
|
||||
*/
|
||||
fun getLocale(): Locale {
|
||||
val langCode =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
|
||||
val language = Language.fromCode(langCode)
|
||||
|
||||
return when (language) {
|
||||
Language.AUTO -> Utils.getSysLocale()
|
||||
Language.ENGLISH -> Locale.ENGLISH
|
||||
Language.CHINA -> Locale.CHINA
|
||||
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
|
||||
Language.VIETNAMESE -> Locale.forLanguageTag("vi")
|
||||
Language.RUSSIAN -> Locale.forLanguageTag("ru")
|
||||
Language.PERSIAN -> Locale.forLanguageTag("fa")
|
||||
Language.ARABIC -> Locale.forLanguageTag("ar")
|
||||
Language.BANGLA -> Locale.forLanguageTag("bn")
|
||||
Language.BAKHTIARI -> Locale.forLanguageTag("bqi-IR")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the currently selected VPN interface address configuration.
|
||||
* This method reads the user's preference for VPN interface addressing and returns
|
||||
@@ -493,7 +467,7 @@ object SettingsManager {
|
||||
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
|
||||
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
|
||||
|
||||
@@ -13,6 +13,11 @@ import java.net.UnknownHostException
|
||||
|
||||
object SpeedtestManager {
|
||||
|
||||
data class RemoteEndpointInfo(
|
||||
val country: String?,
|
||||
val ipAddress: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Measures the time taken to establish a TCP connection to a given URL and port.
|
||||
*
|
||||
@@ -48,7 +53,7 @@ object SpeedtestManager {
|
||||
return -1
|
||||
}
|
||||
|
||||
fun getRemoteIPInfo(): String? {
|
||||
fun getRemoteIPInfo(): RemoteEndpointInfo? {
|
||||
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
||||
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
||||
|
||||
@@ -81,6 +86,9 @@ object SpeedtestManager {
|
||||
ipInfo.location?.country_code
|
||||
).firstOrNull { !it.isNullOrBlank() }
|
||||
|
||||
return "(${country ?: "unknown"}) ${ip ?: "unknown"}"
|
||||
return RemoteEndpointInfo(
|
||||
country = country,
|
||||
ipAddress = ip,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -27,6 +29,37 @@ object MessageHelper {
|
||||
sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an ordered service message and reports whether a daemon receiver handled it.
|
||||
* With no running daemon, the initial canceled result reaches [onResult] unchanged.
|
||||
*/
|
||||
internal fun sendMsg2ServiceForResult(
|
||||
ctx: Context,
|
||||
what: Int,
|
||||
content: Serializable,
|
||||
onResult: (handled: Boolean) -> Unit,
|
||||
) {
|
||||
val resultReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
onResult(resultCode == Activity.RESULT_OK)
|
||||
}
|
||||
}
|
||||
try {
|
||||
ctx.sendOrderedBroadcast(
|
||||
messageIntent(AppConfig.BROADCAST_ACTION_SERVICE, what, content),
|
||||
null,
|
||||
resultReceiver,
|
||||
null,
|
||||
Activity.RESULT_CANCELED,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send ordered message to service", e)
|
||||
onResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the UI.
|
||||
*
|
||||
@@ -115,14 +148,16 @@ object MessageHelper {
|
||||
*/
|
||||
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
|
||||
try {
|
||||
val intent = Intent()
|
||||
intent.action = action
|
||||
intent.`package` = AppConfig.ANG_PACKAGE
|
||||
intent.putExtra("key", what)
|
||||
intent.putExtra("content", content)
|
||||
ctx.sendBroadcast(intent)
|
||||
ctx.sendBroadcast(messageIntent(action, what, content))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageIntent(action: String, what: Int, content: Serializable): Intent =
|
||||
Intent(action).apply {
|
||||
`package` = AppConfig.ANG_PACKAGE
|
||||
putExtra("key", what)
|
||||
putExtra("content", content)
|
||||
}
|
||||
}
|
||||
@@ -77,15 +77,18 @@ object NotificationHelper {
|
||||
* @param channelType The notification channel type
|
||||
* @param title The notification title
|
||||
* @param content The notification content text
|
||||
* @param action An optional action retained when the notification is updated
|
||||
*/
|
||||
fun startForeground(
|
||||
service: Service,
|
||||
channelType: NotificationChannelType,
|
||||
title: String,
|
||||
content: String
|
||||
content: String,
|
||||
action: NotificationCompat.Action? = null
|
||||
) {
|
||||
ensureChannelCreated(channelType, service)
|
||||
val builder = buildNotificationBuilder(channelType, service, title, content)
|
||||
val builder = buildNotificationBuilder(channelType, service, title, content, action)
|
||||
builderCache[channelType.notificationId] = builder
|
||||
service.startForeground(channelType.notificationId, builder.build())
|
||||
}
|
||||
|
||||
@@ -141,7 +144,8 @@ object NotificationHelper {
|
||||
channelType: NotificationChannelType,
|
||||
context: Context,
|
||||
title: String,
|
||||
content: String
|
||||
content: String,
|
||||
action: NotificationCompat.Action? = null
|
||||
): NotificationCompat.Builder {
|
||||
val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
channelType.channelId
|
||||
@@ -158,5 +162,6 @@ object NotificationHelper {
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.apply { action?.let(::addAction) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ class PermissionHelper(private val activity: ComponentActivity) {
|
||||
if (isGranted) {
|
||||
onGranted()
|
||||
} else {
|
||||
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
|
||||
val message = activity.getString(
|
||||
R.string.toast_permission_denied_for,
|
||||
activity.getString(permissionType.getLabelRes())
|
||||
)
|
||||
activity.toast(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.v2ray.ang.root
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Process
|
||||
import android.util.AtomicFile
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
@@ -112,15 +113,25 @@ object RootProxyManager {
|
||||
val socksUsername = SettingsManager.getSocksUsername()
|
||||
val socksPassword = SettingsManager.getSocksPassword()
|
||||
val port = SettingsManager.getSocksPort()
|
||||
val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
|
||||
val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR)
|
||||
if (!runDir.isDirectory && !runDir.mkdirs()) {
|
||||
LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to create runtime directory at ${runDir.absolutePath}")
|
||||
return null
|
||||
}
|
||||
val pidFile = File(runDir, "tun2socks.pid").absolutePath
|
||||
val logFile = File(runDir, "tun2socks.log").absolutePath
|
||||
val cfgFile = File(runDir, "tun2socks.yml").absolutePath
|
||||
val cfgFile = File(runDir, "tun2socks.yml")
|
||||
val oomGuardPid = File(runDir, "oomguard.pid").absolutePath
|
||||
val ipv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)
|
||||
val lanShare = forceLanShare || MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING)
|
||||
val corePid = Process.myPid()
|
||||
|
||||
val config = buildHevConfig(socksUsername, socksPassword, port, ipv6)
|
||||
if (!writeHevConfig(cfgFile, config)) {
|
||||
return null
|
||||
}
|
||||
val cfgPath = cfgFile.absolutePath
|
||||
|
||||
// Per-app proxy/bypass (mirrors what VpnService does via allowed/disallowed apps).
|
||||
val perAppEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)
|
||||
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
|
||||
@@ -141,15 +152,9 @@ object RootProxyManager {
|
||||
appendLine("echo \$! > '$oomGuardPid'")
|
||||
// tun device node
|
||||
appendLine("if [ ! -e /dev/net/tun ]; then mkdir -p /dev/net; mknod /dev/net/tun c 10 200; chmod 666 /dev/net/tun; fi")
|
||||
// hev-socks5-tunnel config: it creates the tun ($TUN) itself and forwards it to the
|
||||
// in-process core's SOCKS inbound on loopback. MTU comes from the existing VPN MTU
|
||||
// setting. No fwmark on hev's sockets: its only upstream connection is to 127.0.0.1
|
||||
// (loopback, already RETURNed by the 127.0.0.0/8 bypass) and the core's real outbound
|
||||
// runs as the app uid (RETURNed by the uid-owner rule), so traffic can't loop.
|
||||
appendLine("cat > '$cfgFile' <<'HEVCFG'")
|
||||
append(buildHevConfig(socksUsername = socksUsername, socksPassword = socksPassword, socksPort = port, ipv6 = ipv6))
|
||||
appendLine("HEVCFG")
|
||||
appendLine("nohup \"\$BIN\" '$cfgFile' >'$logFile' 2>&1 &")
|
||||
// The HEV config is written separately by the app process. Never place credentials
|
||||
// in this root shell script, even when YAML quoting would otherwise be valid.
|
||||
appendLine("nohup \"\$BIN\" '$cfgPath' >'$logFile' 2>&1 &")
|
||||
appendLine("T2S_PID=\$!")
|
||||
appendLine("echo \$T2S_PID > '$pidFile'")
|
||||
appendLine("echo ${AppConfig.ROOT_OOM_SCORE} > /proc/\$T2S_PID/oom_score_adj 2>/dev/null || true")
|
||||
@@ -212,13 +217,33 @@ object RootProxyManager {
|
||||
appendLine(" address: '${AppConfig.LOOPBACK}'")
|
||||
appendLine(" udp: 'udp'")
|
||||
if (socksUsername != null && socksPassword != null) {
|
||||
appendLine(" username: '$socksUsername'")
|
||||
appendLine(" password: '$socksPassword'")
|
||||
appendLine(" username: ${socksUsername.toSingleQuotedYamlScalar()}")
|
||||
appendLine(" password: ${socksPassword.toSingleQuotedYamlScalar()}")
|
||||
}
|
||||
appendLine(" tcp-fastopen: true")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeHevConfig(file: File, config: String): Boolean {
|
||||
val atomicFile = AtomicFile(file)
|
||||
val output = try {
|
||||
atomicFile.startWrite()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to open HEV config at ${file.absolutePath}", e)
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
output.write(config.toByteArray(Charsets.UTF_8))
|
||||
atomicFile.finishWrite(output)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
atomicFile.failWrite(output)
|
||||
LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to write HEV config at ${file.absolutePath}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mangle OUTPUT marking chain (ipv4/ipv6). Mirrors VpnService's capture behavior:
|
||||
* - all-apps (no per-app): mark EVERY remaining uid (incl uid 0 + all system uids), so
|
||||
@@ -473,3 +498,5 @@ object RootProxyManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun String.toSingleQuotedYamlScalar(): String = "'${replace("'", "''")}'"
|
||||
@@ -0,0 +1,92 @@
|
||||
# Service code agent guide
|
||||
|
||||
These rules apply to the service and service-adjacent paths listed in the repository-root
|
||||
`AGENTS.md`. Follow both guides; apply the precedence rule defined by the root guide.
|
||||
|
||||
## Process and lifecycle invariants
|
||||
|
||||
- The daemon process `:RunSoLibV2RayDaemon` is the only authority for whether the native
|
||||
core is running. Do not use a UI-process singleton, cached Boolean, bound-service
|
||||
assumption, or successful broadcast send as proof of daemon state.
|
||||
- Route every app-initiated VPN, proxy-only, and root start through `LauncherManager`.
|
||||
Route service commands and their results through `MessageHelper`. Put lifecycle state
|
||||
shared by two or more run modes in `CoreServiceManager` or `ServiceControl`. Do not
|
||||
load or query the native core from the UI process to decide start, stop, or restart.
|
||||
- Keep Android-owned `VpnService` entry paths intact. `CoreVpnService` must accept a null
|
||||
restart intent and the `VpnService.SERVICE_INTERFACE` action without requiring the
|
||||
app-initiated `LauncherManager` path.
|
||||
- A start path is idempotent only when a second equivalent command leaves exactly one
|
||||
native core, tunnel, foreground notification update loop, worker set, and root rule
|
||||
set. Every edit to start dispatch or `onStartCommand` must preserve that result.
|
||||
- In each `onStartCommand`, enter foreground before reading configuration, opening a
|
||||
file or socket, starting a coroutine that performs setup, invoking native code, or
|
||||
executing a root command. `CoreVpnService`, `CoreProxyOnlyService`, and
|
||||
`CoreRootService` must call `NotificationManager.ensureForeground()`;
|
||||
`CoreTestService` and `SubscriptionUpdateService` must call
|
||||
`NotificationHelper.startForeground()`.
|
||||
- On setup failure, cancel the setup job, release every resource created by that attempt,
|
||||
remove partial root or VPN state, and stop the failed service instance. Do not return a
|
||||
sticky restart mode unless `CoreServiceManager.isRunning()` is true, the service owns
|
||||
every tunnel or root resource required by its run mode, and the Android restart path
|
||||
reconstructs configuration, native core, notification, and mode-owned resources
|
||||
without UI-process state.
|
||||
- Preserve these ownership boundaries: `CoreVpnService` owns the VPN interface and
|
||||
socket protection; `CoreProxyOnlyService` owns local-proxy mode; `CoreRootService`
|
||||
owns root routing. Code used by two or more of these services belongs in their common
|
||||
lifecycle layer. Code used by one mode remains in that mode's service.
|
||||
|
||||
## Cleanup, concurrency, networking, and IPC
|
||||
|
||||
- Teardown must prevent new work before releasing dependencies. Mark the service as
|
||||
stopping, reject or invalidate pending start/reload work, cancel and join setup jobs,
|
||||
then release owned resources. Remove root routing before stopping its core listener.
|
||||
Close each VPN descriptor and tun2socks resource once on setup failure, revoke, stop,
|
||||
and destroy; make repeated teardown calls no-ops after the first close.
|
||||
- Every new coroutine launched by a service must be a child of a job or scope stored by
|
||||
that service or the shared lifecycle owner. Cancel that owner during stop and
|
||||
`onDestroy`. Do not use `GlobalScope`, an anonymous standalone `CoroutineScope`, or a
|
||||
job whose parent outlives the service and later recreates routes, notifications,
|
||||
files, or native state.
|
||||
- Do not add root commands, file I/O, network probes, or native calls directly to
|
||||
`onCreate`, `onStartCommand`, `onRevoke`, or `onDestroy` on the main thread. Perform
|
||||
the operation in the service-owned scope. If traffic-leak prevention requires the
|
||||
callback to await completion, the call site must contain an explicit timeout and a
|
||||
comment naming the leak-prevention ordering; an unbounded wait is prohibited.
|
||||
- A handover belongs to the `NetworkMonitor` instance that scheduled it.
|
||||
`NetworkMonitor.unregister()` must cancel pending handover work and prevent its
|
||||
callback from entering after `unregister()` returns. Immediately before reload, the
|
||||
handover handler must verify that its monitor instance is still the instance owned by
|
||||
`CoreServiceManager` and that the core is running. Stop and destroy must unregister
|
||||
the monitor before clearing it or stopping the core.
|
||||
- Every app-internal broadcast intent must set its package to `AppConfig.ANG_PACKAGE`;
|
||||
every app-internal service intent must use an explicit `ComponentName`. Do not add an
|
||||
implicit broadcast or service intent. Each payload must implement the serialization
|
||||
contract used by its receiver. When a caller must distinguish `handled by daemon`
|
||||
from `daemon absent`, return an acknowledgement/result from the daemon. Broadcast
|
||||
delivery, bind success, and command enqueue success are not acknowledgements.
|
||||
|
||||
## Native resources, logging, and validation
|
||||
|
||||
- `TProxyService` loads the JNI library `libhev-socks5-tunnel.so`; root mode executes
|
||||
the separately packaged `libhevsockstun.so`. A native or packaging diff must inspect
|
||||
the produced APK as a ZIP and confirm both HEV files and `libgojni.so` for every ABI
|
||||
selected by `ABI_FILTERS` or, when that property is absent, every ABI in the app's
|
||||
default `splits.abi` block.
|
||||
- A recoverable start, stop, reload, handover, or cleanup failure log must include the
|
||||
run mode, lifecycle phase, stable non-secret profile/group identifier when one exists,
|
||||
failed operation, and exception. Apply the root guide's secret-redaction rule.
|
||||
- Move deterministic lifecycle decisions into pure helpers and add a JVM regression test
|
||||
for each changed decision. Apply this scenario mapping to service changes:
|
||||
- Start dispatch, `onStartCommand`, or command deduplication: cold start and two
|
||||
equivalent consecutive start commands.
|
||||
- Foreground setup or setup-error handling: successful setup and failure after at
|
||||
least one resource has been acquired.
|
||||
- Stop or teardown: normal stop, repeated stop, and stop while setup is in flight.
|
||||
- Restart or network handover: successful restart/handover and a stop racing the
|
||||
pending restart/handover.
|
||||
- Shared lifecycle code: run the mapped scenarios in VPN, proxy-only, and root modes.
|
||||
Mode-owned code: run them in the owning mode.
|
||||
- Every service lifecycle scenario above requires an emulator or physical device; a JVM
|
||||
test or assembled APK is not a substitute. Record each scenario that did not run under
|
||||
`Not run`, with the device requirement or blocker. Do not claim the service change is
|
||||
runtime-verified when any mapped scenario is unrun.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -7,10 +7,9 @@ import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
class CoreProxyOnlyService : Service(), ServiceControl {
|
||||
@@ -34,6 +33,11 @@ class CoreProxyOnlyService : Service(), ServiceControl {
|
||||
NotificationManager.ensureForeground()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
|
||||
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Core is already running")
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
if (!CoreServiceManager.startCoreLoop(null)) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Proxy: Failed to start core loop")
|
||||
stopSelf()
|
||||
@@ -96,9 +100,7 @@ class CoreProxyOnlyService : Service(), ServiceControl {
|
||||
* @param newBase The new base context.
|
||||
*/
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
val context = newBase?.let {
|
||||
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
|
||||
}
|
||||
val context = newBase?.let(AppLocaleManager::localizedContext)
|
||||
super.attachBaseContext(context)
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,10 @@ import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.root.RootProxyManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -43,6 +42,11 @@ class CoreRootService : Service(), ServiceControl {
|
||||
NotificationManager.ensureForeground()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Root: command received")
|
||||
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Root: Core is already running")
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
// Start the in-process core first (this also posts the foreground notification),
|
||||
// then install the root routing off the main thread.
|
||||
if (!CoreServiceManager.startCoreLoop(null)) {
|
||||
@@ -90,9 +94,7 @@ class CoreRootService : Service(), ServiceControl {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
val context = newBase?.let {
|
||||
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
|
||||
}
|
||||
val context = newBase?.let(AppLocaleManager::localizedContext)
|
||||
super.attachBaseContext(context)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
@@ -10,6 +13,8 @@ import com.v2ray.ang.dto.RealPingEvent
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.enums.NotificationChannelType
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.helper.NotificationHelper
|
||||
@@ -18,8 +23,29 @@ import java.util.Collections
|
||||
|
||||
class CoreTestService : Service() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
|
||||
}
|
||||
|
||||
// manage active batch workers so each batch is independent and cancellable
|
||||
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
|
||||
private val cancelAction by lazy {
|
||||
val intent = Intent(this, CoreTestService::class.java).putExtra(
|
||||
"content",
|
||||
TestServiceMessage(AppConfig.MSG_MEASURE_CONFIG_CANCEL)
|
||||
)
|
||||
val pendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
NotificationChannelType.CORE_TEST.notificationId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_stop_24dp,
|
||||
getString(R.string.action_cancel),
|
||||
pendingIntent
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the V2Ray environment.
|
||||
@@ -63,7 +89,8 @@ class CoreTestService : Service() {
|
||||
this,
|
||||
NotificationChannelType.CORE_TEST,
|
||||
getString(R.string.app_name),
|
||||
getString(R.string.title_real_ping_all_server)
|
||||
getString(R.string.title_real_ping_all_server),
|
||||
cancelAction
|
||||
)
|
||||
val message = intent?.serializable<TestServiceMessage>("content")
|
||||
if (message == null) {
|
||||
@@ -96,7 +123,7 @@ class CoreTestService : Service() {
|
||||
context = this,
|
||||
guids = guidsList,
|
||||
onlyTcp = message.onlyTcp,
|
||||
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
|
||||
onEvent = { event -> handleWorkerEvent(event, message) { activeWorkers.remove(worker) } }
|
||||
)
|
||||
activeWorkers.add(worker)
|
||||
worker.start()
|
||||
@@ -106,14 +133,14 @@ class CoreTestService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
|
||||
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, onWorkerDone: () -> Unit) {
|
||||
when (event) {
|
||||
is RealPingEvent.Progress -> {
|
||||
NotificationHelper.updateNotification(
|
||||
channelType = NotificationChannelType.CORE_TEST,
|
||||
context = this,
|
||||
title = getString(R.string.app_name),
|
||||
content = getString(R.string.connection_runing_task_left, event.text)
|
||||
content = getString(R.string.connection_running_task_left, event.text)
|
||||
)
|
||||
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
|
||||
}
|
||||
@@ -124,6 +151,16 @@ class CoreTestService : Service() {
|
||||
}
|
||||
|
||||
is RealPingEvent.Finish -> {
|
||||
if (message.subscriptionId.isNotEmpty()) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
|
||||
AngConfigManager.removeInvalidServer(message.subscriptionId)
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
|
||||
AngConfigManager.sortByTestResultsForSub(message.subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
|
||||
onWorkerDone()
|
||||
if (activeWorkers.isEmpty()) {
|
||||
@@ -135,6 +172,7 @@ class CoreTestService : Service() {
|
||||
}
|
||||
|
||||
private fun handleMeasureCancel() {
|
||||
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, "0")
|
||||
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
|
||||
val snapshot = ArrayList(activeWorkers)
|
||||
snapshot.forEach { it.cancel() }
|
||||
|
||||
@@ -17,12 +17,12 @@ import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.contracts.Tun2SocksControl
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.root.RootLanSharing
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.lang.ref.SoftReference
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -129,9 +129,7 @@ class CoreVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
val context = newBase?.let {
|
||||
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
|
||||
}
|
||||
val context = newBase?.let(AppLocaleManager::localizedContext)
|
||||
super.attachBaseContext(context)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ package com.v2ray.ang.service
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.IDialerService
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -5,12 +5,12 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.core.LauncherManager
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -19,6 +20,10 @@ import java.lang.ref.SoftReference
|
||||
|
||||
class QSTileService : TileService() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the state of the tile.
|
||||
* @param state The state to set.
|
||||
|
||||
@@ -15,11 +15,29 @@ import kotlinx.coroutines.CoroutineName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
internal object RealPingExecutionLimiter {
|
||||
private val customConfigMutex = Mutex()
|
||||
|
||||
suspend fun <T> run(configType: EConfigType, block: () -> T): T {
|
||||
// Custom profiles bypass speed-test trimming and start complete Xray configs.
|
||||
// Parallel teardown can abort the native probe process, so serialize their
|
||||
// JNI measurements globally across batches.
|
||||
return if (configType == EConfigType.CUSTOM) {
|
||||
customConfigMutex.withLock { block() }
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker that runs a batch of real-ping tests independently.
|
||||
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
|
||||
@@ -45,13 +63,17 @@ class RealPingWorkerService(
|
||||
runningCount.incrementAndGet()
|
||||
try {
|
||||
val result = if (onlyTcp) startTcping(guid) else startRealPing(guid)
|
||||
onEvent(RealPingEvent.Result(guid, result))
|
||||
if (scope.isActive) {
|
||||
onEvent(RealPingEvent.Result(guid, result))
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
} finally {
|
||||
val count = totalCount.decrementAndGet()
|
||||
val left = runningCount.decrementAndGet()
|
||||
onEvent(RealPingEvent.Progress("$left / $count"))
|
||||
if (scope.isActive) {
|
||||
onEvent(RealPingEvent.Progress("$left / $count"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,9 +81,11 @@ class RealPingWorkerService(
|
||||
scope.launch {
|
||||
try {
|
||||
joinAll(*jobs.toTypedArray())
|
||||
onEvent(RealPingEvent.Finish("0"))
|
||||
if (isActive) {
|
||||
onEvent(RealPingEvent.Finish("0"))
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
onEvent(RealPingEvent.Finish("-1"))
|
||||
// If cancelled, don't send finish event to avoid confusion
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
@@ -80,7 +104,7 @@ class RealPingWorkerService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRealPing(guid: String): Long {
|
||||
private suspend fun startRealPing(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
|
||||
@@ -103,7 +127,9 @@ class RealPingWorkerService(
|
||||
if (!configResult.status) {
|
||||
return retFailure
|
||||
}
|
||||
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
return RealPingExecutionLimiter.run(config.configType) {
|
||||
CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTcping(guid: String): Long {
|
||||
@@ -113,7 +139,7 @@ class RealPingWorkerService(
|
||||
if (!config.configType.isComplexType()
|
||||
&& config.configType != EConfigType.HYSTERIA2
|
||||
&& config.configType != EConfigType.WIREGUARD
|
||||
&& config.alpn?.startsWith("h3") != true
|
||||
&& config.alpn?.split(',')?.all { it.trim().startsWith("h3") } != true
|
||||
&& config.server.isNotNullEmpty()
|
||||
&& config.serverPort?.toIntOrNull() != null
|
||||
) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.enums.NotificationChannelType
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.NotificationHelper
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -28,6 +29,10 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class SubscriptionUpdateService : Service() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(newBase?.let(AppLocaleManager::localizedContext))
|
||||
}
|
||||
|
||||
private val serviceJob = Job()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
||||
|
||||
@@ -118,7 +123,7 @@ class SubscriptionUpdateService : Service() {
|
||||
showNotification(
|
||||
context = this,
|
||||
titleResId = R.string.title_pref_auto_update_subscription,
|
||||
content = "Updating ${subItem.remarks}"
|
||||
content = getString(R.string.subscription_update_updating, subItem.remarks)
|
||||
)
|
||||
|
||||
if (forcedUpdate || MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)) {
|
||||
@@ -184,13 +189,17 @@ class SubscriptionUpdateService : Service() {
|
||||
private fun handleWorkerEvent(event: RealPingEvent, remarks: String, onWorkerDone: () -> Unit) {
|
||||
when (event) {
|
||||
is RealPingEvent.Progress -> {
|
||||
val text = "${event.text} in $remarks"
|
||||
val notificationText = getString(
|
||||
R.string.subscription_update_progress,
|
||||
event.text,
|
||||
remarks
|
||||
)
|
||||
showNotification(
|
||||
context = this,
|
||||
titleResId = R.string.title_real_ping_all_server,
|
||||
content = text
|
||||
content = notificationText
|
||||
)
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: $text")
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: ${event.text} in $remarks")
|
||||
}
|
||||
|
||||
is RealPingEvent.Result -> {
|
||||
|
||||
@@ -21,11 +21,15 @@ class TProxyService(
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyStartService(configPath: String, fd: Int)
|
||||
private external fun TProxyStartService(configPath: String, fd: Int): Boolean
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyStopService()
|
||||
private external fun TProxyStopService(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyIsRunning(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
@@ -93,7 +97,7 @@ class TProxyService(
|
||||
appendLine("misc:")
|
||||
appendLine(" tcp-read-write-timeout: ${tcpTimeout * 1000}")
|
||||
appendLine(" udp-read-write-timeout: ${udpTimeout * 1000}")
|
||||
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) ?: "warn"}")
|
||||
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# UI code agent guide
|
||||
|
||||
These rules apply to the UI paths listed in the repository-root `AGENTS.md`. Follow both
|
||||
guides; apply the precedence rule defined by the root guide.
|
||||
|
||||
## Compose architecture and state
|
||||
|
||||
- Implement every new screen and component with the existing Compose and Material 3
|
||||
stack. A new screen activity must extend `BaseComponentActivity` or
|
||||
`HelperBaseComponentActivity` and place its screen, ViewModel, and action contract in
|
||||
the package that owns its navigation entry. Do not add a fragment, ViewBinding layout,
|
||||
XML screen layout, or RecyclerView adapter. Do not migrate existing legacy UI outside
|
||||
task scope, and do not extend it or use it as the template for new UI.
|
||||
- Keep durable screen state and business work in a ViewModel/repository and expose it as
|
||||
immutable observable state. Collect flows with `collectAsStateWithLifecycle()`.
|
||||
Compose-local state is only for transient presentation state; use `rememberSaveable`
|
||||
when that state must survive activity recreation.
|
||||
- Durable state includes loaded data, selection by domain ID, validation results,
|
||||
progress, errors, and any value written to settings or a profile. Transient
|
||||
presentation state is limited to visual expansion, animation, scroll/focus position,
|
||||
and an uncommitted input value owned solely by the visible component. A composable
|
||||
must not perform repository, MMKV, import/export, routing, or native-core work.
|
||||
- Send user operations through the owning feature's action/event contract and
|
||||
ViewModel. Main-screen operations must use `MainAction` and `MainViewModel`.
|
||||
Composables must not mutate a repository, `MmkvManager`, or `SettingsManager`
|
||||
directly.
|
||||
- Use server GUIDs and group IDs for Compose keys, selection, saved state, and action
|
||||
parameters. Do not persist or dispatch a visible index, adapter position, or paging
|
||||
offset as identity. After filtering, sorting, subscription replacement, paging, or an
|
||||
asynchronous result, resolve the item again by its domain ID.
|
||||
- Run blocking file, network, package-manager, and bitmap-decoding operations on
|
||||
`Dispatchers.IO`. Run CPU-bound parsing, sorting, filtering, and transformation on
|
||||
`Dispatchers.Default`. Launch both from the owning ViewModel or lifecycle scope.
|
||||
Publish results through a private `MutableStateFlow` exposed as `StateFlow`; do not
|
||||
read or write Compose snapshot state on either background dispatcher.
|
||||
|
||||
## Interaction, accessibility, and layout
|
||||
|
||||
- Give every icon with a click, long-click, toggle, or custom accessibility action a
|
||||
localized accessible name that states its action. Set `contentDescription = null` on
|
||||
every icon with no user action. Do not expose a raw URL,
|
||||
package ID, GUID, or duplicated descendant label when a row-level semantic node
|
||||
supplies the name.
|
||||
- A row with one activation behavior must expose one focusable semantic node, one
|
||||
localized name, its current selected/on/off state, and one activation action. Merge or
|
||||
clear descendant semantics so a label, icon, checkbox, and switch do not become
|
||||
duplicate focus targets. Keep a descendant as a separate node only when it performs a
|
||||
different user action; give that node its own name and role. TalkBack, touch, Enter,
|
||||
Space, and D-pad center must invoke the same action for the same node.
|
||||
- Key each focusable list item by its domain ID. Reordering or recomposition must keep
|
||||
focus on the node with that ID if it still exists. If a selection change removes or
|
||||
replaces the focused node, move focus exactly once to the final active item after the
|
||||
state update; do not announce or focus an intermediate item.
|
||||
- Use `stringResource` for text created inside a composable and localized
|
||||
`Context.getString`/resources outside Compose. This rule covers visible labels,
|
||||
semantic labels, state descriptions, validation messages, errors, dialogs, snackbars,
|
||||
and toasts. Apply the root guide's locale and placeholder requirements to every key.
|
||||
- Every form containing a text-editing control must use a vertically scrollable
|
||||
container. Inside a `Scaffold`, apply its `innerPadding`, call
|
||||
`consumeWindowInsets(innerPadding)`, and
|
||||
apply `imePadding()` to that container. Outside a `Scaffold`, apply `imePadding()` and
|
||||
consume the navigation-bar inset exactly once. With the IME visible, the user must be
|
||||
able to focus, read, edit, and activate both the first and last form controls without
|
||||
dismissing the IME.
|
||||
- A shared component used by server screens must preserve edge-to-edge system-bar
|
||||
insets, light and dark themes, dynamic color enabled and disabled, and both single-
|
||||
and double-column layouts. A change to such a component requires rendering and
|
||||
interaction checks in each of those configurations; dynamic-color checks require a
|
||||
device or emulator that supports dynamic color.
|
||||
|
||||
## Validation mapping
|
||||
|
||||
Apply every row whose trigger matches the UI diff:
|
||||
|
||||
- ViewModel state, action mapping, validation, filtering, parsing, or selection logic:
|
||||
add or update a JVM test that asserts the initial state and every success, invalid-
|
||||
input, empty-input, and failure branch added or modified by the diff.
|
||||
- `rememberSaveable`, `SavedStateHandle`, activity recreation, or restoration logic:
|
||||
recreate the activity and verify the final state by domain ID, not list position.
|
||||
- Loading or data presentation: exercise every state represented by the owning state
|
||||
model among empty, loading, error, and populated; do not invent states absent from
|
||||
that model.
|
||||
- Click, toggle, selection, or navigation behavior: verify touch, keyboard focus and
|
||||
activation, and D-pad focus and activation.
|
||||
- Semantics, focus, accessible text, row merging, or icon meaning: inspect the semantics
|
||||
tree and run TalkBack on an emulator or device. Record the spoken label, state, focus
|
||||
order, and activation result.
|
||||
- Form fields, scrolling, or insets: open the IME, traverse from the first control to the
|
||||
last control, and activate the primary action with the IME still visible.
|
||||
- A setting, profile, routing rule, or other persisted value: perform the action, close
|
||||
and reopen the screen, and verify the value through the owning ViewModel/repository.
|
||||
|
||||
A screenshot verifies appearance only. It does not verify semantics, focus, activation,
|
||||
state ownership, persistence, or recreation. Record every mapped check that did not run
|
||||
under `Not run`; do not claim the corresponding behavior is verified.
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -12,7 +14,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -32,6 +33,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.VersionInfoBlock
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -44,20 +46,29 @@ class AboutActivity : BaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
AboutScreen(onBackClick = { finish() })
|
||||
AboutScreen(
|
||||
onBackClick = { finish() },
|
||||
onTranslatorsClick = {
|
||||
startActivity(Intent(this, TranslatorsActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AboutScreen(onBackClick: () -> Unit) {
|
||||
fun AboutScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onTranslatorsClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showOssDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
|
||||
val libVersion = CoreNativeManager.getLibVersion()
|
||||
val versionText = "v${BuildConfig.VERSION_NAME} ($libVersion)"
|
||||
val appIdText = BuildConfig.APPLICATION_ID
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_about),
|
||||
@@ -81,6 +92,11 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
title = stringResource(R.string.title_oss_license),
|
||||
onClick = { showOssDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_translate_24dp),
|
||||
title = stringResource(R.string.title_translators),
|
||||
onClick = onTranslatorsClick
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_feedback_24dp),
|
||||
title = stringResource(R.string.title_pref_feedback),
|
||||
@@ -100,6 +116,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
versionText = versionText,
|
||||
appIdText = appIdText
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +138,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showOssDialog = false }) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
/**
|
||||
* Pure selection operations shared by application-list screens.
|
||||
*
|
||||
* Keeping the calculations separate lets bulk actions build one result before it is published.
|
||||
*/
|
||||
internal object AppSelection {
|
||||
|
||||
fun invert(currentSelection: Set<String>, packageNames: Collection<String>): Set<String> {
|
||||
return currentSelection.toMutableSet().apply {
|
||||
packageNames.forEach { packageName ->
|
||||
if (!add(packageName)) {
|
||||
remove(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the selected package set while preserving the legacy list matching behavior.
|
||||
*
|
||||
* The full list text is searched for each installed package name. This is intentionally not
|
||||
* exact line matching; changing that behavior should be handled separately.
|
||||
*/
|
||||
fun fromProxyList(
|
||||
packageNames: Collection<String>,
|
||||
proxyAppList: String,
|
||||
bypassApps: Boolean,
|
||||
forceGoogleApps: Boolean
|
||||
): Set<String> {
|
||||
return buildSet(packageNames.size) {
|
||||
packageNames.forEach { packageName ->
|
||||
val shouldProxy = shouldProxy(packageName, proxyAppList, forceGoogleApps)
|
||||
val shouldSelect = if (bypassApps) !shouldProxy else shouldProxy
|
||||
if (shouldSelect) {
|
||||
add(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldProxy(packageName: String, proxyAppList: String, forceGoogleApps: Boolean): Boolean {
|
||||
if (forceGoogleApps) {
|
||||
if (packageName == "com.google.android.webview") return false
|
||||
if (packageName.startsWith("com.google")) return true
|
||||
}
|
||||
return proxyAppList.contains(packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -69,6 +69,7 @@ import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import android.util.Size as TargetSize
|
||||
@@ -178,7 +179,9 @@ fun ScannerScreen(
|
||||
if (isScanning) R.drawable.ic_stop_24dp
|
||||
else R.drawable.ic_scan_24dp
|
||||
),
|
||||
contentDescription = if (isScanning) "stop scan" else "start scan"
|
||||
contentDescription = stringResource(
|
||||
if (isScanning) R.string.acc_stop_scanner else R.string.acc_start_scanner
|
||||
)
|
||||
)
|
||||
}
|
||||
if (isScanning && hasTorch) {
|
||||
@@ -193,14 +196,17 @@ fun ScannerScreen(
|
||||
if (torchEnabled) R.drawable.ic_flash_on_24dp
|
||||
else R.drawable.ic_flash_off_24dp
|
||||
),
|
||||
contentDescription = "Torch"
|
||||
contentDescription = stringResource(
|
||||
if (torchEnabled) R.string.acc_turn_torch_off
|
||||
else R.string.acc_turn_torch_on
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSelectPhoto) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_image_24dp),
|
||||
contentDescription = "select image"
|
||||
contentDescription = stringResource(R.string.acc_select_image)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -244,7 +250,7 @@ private fun ScannerIdlePlaceholder(onStartClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_scan_24dp),
|
||||
contentDescription = "Start Scanner",
|
||||
contentDescription = stringResource(R.string.acc_start_scanner),
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
@@ -410,15 +416,14 @@ private fun processImageProxy(
|
||||
return
|
||||
}
|
||||
try {
|
||||
val buffer = imageProxy.planes[0].buffer
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
val width = imageProxy.width
|
||||
val height = imageProxy.height
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
bytes, width, height,
|
||||
0, 0, width, height,
|
||||
false
|
||||
val yPlane = imageProxy.planes[0]
|
||||
val source = createYPlaneLuminanceSource(
|
||||
buffer = yPlane.buffer,
|
||||
width = width,
|
||||
height = height,
|
||||
rowStride = yPlane.rowStride,
|
||||
)
|
||||
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
|
||||
val hints = mapOf(
|
||||
@@ -437,3 +442,26 @@ private fun processImageProxy(
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun createYPlaneLuminanceSource(
|
||||
buffer: ByteBuffer,
|
||||
width: Int,
|
||||
height: Int,
|
||||
rowStride: Int,
|
||||
): PlanarYUVLuminanceSource {
|
||||
val bufferCopy = buffer.duplicate()
|
||||
val bytes = ByteArray(bufferCopy.remaining())
|
||||
bufferCopy.get(bytes)
|
||||
// Camera HALs may pad each Y row. ZXing advances rows by dataWidth, so the
|
||||
// reported row stride must be used instead of the visible image width.
|
||||
return PlanarYUVLuminanceSource(
|
||||
bytes,
|
||||
rowStride,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.TranslatorsCredit
|
||||
import com.v2ray.ang.dto.TranslatorsParser
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class TranslatorsActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
TranslatorsScreen(onBackClick = { finish() })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TranslatorsScreen(onBackClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val credits = remember {
|
||||
TranslatorsParser.parse(Utils.readTextFromAssets(context, "translators.json"))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_translators),
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(credits, key = { it.language }) { credit ->
|
||||
TranslationCreditCard(credit)
|
||||
}
|
||||
item {
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TranslationCreditCard(credit: TranslatorsCredit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = credit.language,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
credit.contributors.forEachIndexed { index, contributor ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
ContributorRow(
|
||||
displayName = contributor.displayName ?: contributor.name,
|
||||
linkUrl = contributor.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContributorRow(displayName: String, linkUrl: String?) {
|
||||
val context = LocalContext.current
|
||||
val isLink = linkUrl != null
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isLink) {
|
||||
Modifier.clickable(role = Role.Button) {
|
||||
Utils.openUri(context, linkUrl)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isLink) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_github_24dp),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (isLink) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier = Modifier.padding(start = if (isLink) 12.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,19 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -33,11 +31,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppListItem
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
|
||||
private enum class AppPickerMenuAction(@StringRes val labelRes: Int) {
|
||||
SelectAll(R.string.menu_item_select_all),
|
||||
InvertSelection(R.string.menu_item_invert_selection)
|
||||
}
|
||||
|
||||
class AppPickerActivity : BaseComponentActivity() {
|
||||
|
||||
companion object {
|
||||
@@ -101,7 +106,6 @@ class AppPickerActivity : BaseComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppPickerScreen(
|
||||
title: String,
|
||||
@@ -119,12 +123,12 @@ fun AppPickerScreen(
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
LaunchedEffect(Unit) {
|
||||
onSearch(searchQuery)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = title,
|
||||
@@ -134,9 +138,11 @@ fun AppPickerScreen(
|
||||
searchQuery = searchQuery,
|
||||
onSearchQueryChange = { query ->
|
||||
searchQuery = query
|
||||
onSearch(query)
|
||||
},
|
||||
onSearchClose = {
|
||||
searchQuery = ""
|
||||
onSearch("")
|
||||
showSearch = false
|
||||
},
|
||||
searchPlaceholder = stringResource(R.string.menu_item_search),
|
||||
@@ -145,7 +151,7 @@ fun AppPickerScreen(
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_search_24dp),
|
||||
contentDescription = stringResource(R.string.menu_item_search)
|
||||
contentDescription = stringResource(R.string.acc_search)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -161,14 +167,13 @@ fun AppPickerScreen(
|
||||
onDismissRequest = { showMenu = false },
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_select_all)) },
|
||||
onClick = { showMenu = false; onSelectAll() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_invert_selection)) },
|
||||
onClick = { showMenu = false; onInvertSelection() }
|
||||
)
|
||||
AppDropdownMenuItems(AppPickerMenuAction.entries, { it.labelRes }) { action ->
|
||||
showMenu = false
|
||||
when (action) {
|
||||
AppPickerMenuAction.SelectAll -> onSelectAll()
|
||||
AppPickerMenuAction.InvertSelection -> onInvertSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,14 +185,15 @@ fun AppPickerScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = selectedPackages.contains(app.packageName)
|
||||
AppListItem(
|
||||
appName = app.appName,
|
||||
packageName = app.packageName,
|
||||
icon = app.appIcon,
|
||||
icon = null,
|
||||
checked = checked,
|
||||
onCheckedChange = { onToggleApp(app.packageName) }
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.ui.AppSelection
|
||||
import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -40,16 +41,30 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
|
||||
}
|
||||
|
||||
fun loadApps(context: Context) {
|
||||
if (allApps != null || isAppListLoading) return
|
||||
allApps?.let { apps ->
|
||||
val localizedUnknownApp = getString(R.string.app_picker_unknown_app)
|
||||
val updatedApps = apps.map { appInfo ->
|
||||
if (appInfo.packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
|
||||
appInfo.copy(appName = localizedUnknownApp)
|
||||
} else {
|
||||
appInfo
|
||||
}
|
||||
}
|
||||
allApps = sortApps(updatedApps)
|
||||
_displayedApps.value = applyFilter(currentQuery)
|
||||
return
|
||||
}
|
||||
if (isAppListLoading) return
|
||||
|
||||
val applicationContext = context.applicationContext
|
||||
val localizedUnknownApp = getString(R.string.app_picker_unknown_app)
|
||||
isAppListLoading = true
|
||||
launchLoading {
|
||||
try {
|
||||
selectedSnapshot = _selectedPackages.value
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val list = AppManagerUtil.loadNetworkAppList(applicationContext)
|
||||
val special = createSpecialItemUnidentified(applicationContext)
|
||||
val special = createSpecialItemUnidentified(localizedUnknownApp)
|
||||
sortApps(list + special)
|
||||
}
|
||||
allApps = apps
|
||||
@@ -80,15 +95,17 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
|
||||
_selectedPackages.value = _selectedPackages.value + displayedPackages
|
||||
val displayedApps = _displayedApps.value
|
||||
val currentSelection = _selectedPackages.value
|
||||
_selectedPackages.value = buildSet(currentSelection.size + displayedApps.size) {
|
||||
addAll(currentSelection)
|
||||
displayedApps.forEach { add(it.packageName) }
|
||||
}
|
||||
}
|
||||
|
||||
fun invertSelection() {
|
||||
val current = _selectedPackages.value
|
||||
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
|
||||
_selectedPackages.value = (current - displayedPackages) +
|
||||
(displayedPackages - current)
|
||||
val packageNames = _displayedApps.value.map { it.packageName }
|
||||
_selectedPackages.value = AppSelection.invert(_selectedPackages.value, packageNames)
|
||||
}
|
||||
|
||||
fun getSelectedPackages(): List<String> = _selectedPackages.value.sorted()
|
||||
@@ -97,9 +114,9 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
|
||||
val apps = allApps ?: return emptyList()
|
||||
if (query.isBlank()) return apps
|
||||
|
||||
val key = query.uppercase()
|
||||
return apps.filter {
|
||||
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
|
||||
it.appName.contains(query, ignoreCase = true) ||
|
||||
it.packageName.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,14 +136,10 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSpecialItemUnidentified(context: Context): AppInfo {
|
||||
val icon = context.getDrawable(android.R.drawable.ic_menu_help)
|
||||
?: context.getDrawable(android.R.drawable.sym_def_app_icon)
|
||||
?: error("No fallback drawable available")
|
||||
private fun createSpecialItemUnidentified(appName: String): AppInfo {
|
||||
return AppInfo(
|
||||
appName = context.getString(R.string.app_picker_unknown_app),
|
||||
appName = appName,
|
||||
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
|
||||
appIcon = icon,
|
||||
isSystemApp = false,
|
||||
isSelected = 0
|
||||
)
|
||||
|
||||
@@ -4,15 +4,16 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,8 +38,10 @@ import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.InputDialog
|
||||
import com.v2ray.ang.ui.compose.InputField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -48,14 +51,15 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
enum class BackupLocation(@StringRes val labelRes: Int) {
|
||||
Local(R.string.backup_location_local),
|
||||
WebDav(R.string.backup_location_webdav)
|
||||
}
|
||||
|
||||
class BackupActivity : HelperBaseComponentActivity() {
|
||||
|
||||
private val viewModel: BackupViewModel by viewModels()
|
||||
|
||||
private val configBackupOptions: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.config_backup_options)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
observeViewModel()
|
||||
@@ -90,21 +94,20 @@ class BackupActivity : HelperBaseComponentActivity() {
|
||||
BackupScreen(
|
||||
isLoadingState = viewModel.isLoading,
|
||||
webDavConfigState = viewModel.webDavConfig,
|
||||
backupOptions = configBackupOptions.toList(),
|
||||
onBackupOptionSelected = { which ->
|
||||
when (which) {
|
||||
0 -> backupViaLocal()
|
||||
1 -> viewModel.backupViaWebDav(cacheDir, getString(R.string.app_name))
|
||||
onBackupOptionSelected = { location ->
|
||||
when (location) {
|
||||
BackupLocation.Local -> backupViaLocal()
|
||||
BackupLocation.WebDav -> viewModel.backupViaWebDav(cacheDir, getString(R.string.app_name))
|
||||
}
|
||||
},
|
||||
onShareClick = { viewModel.shareBackup(cacheDir, getString(R.string.app_name)) },
|
||||
restoreOptions = configBackupOptions.toList(),
|
||||
onRestoreOptionSelected = { which ->
|
||||
when (which) {
|
||||
0 -> restoreViaLocal()
|
||||
1 -> viewModel.restoreViaWebDav(cacheDir)
|
||||
onRestoreOptionSelected = { location ->
|
||||
when (location) {
|
||||
BackupLocation.Local -> restoreViaLocal()
|
||||
BackupLocation.WebDav -> viewModel.restoreViaWebDav(cacheDir)
|
||||
}
|
||||
},
|
||||
onCleanupProfiles = viewModel::cleanupProfileStorage,
|
||||
onWebDavSave = { config -> viewModel.saveWebDavConfig(config) },
|
||||
onBackClick = { finish() }
|
||||
)
|
||||
@@ -183,11 +186,10 @@ class BackupActivity : HelperBaseComponentActivity() {
|
||||
fun BackupScreen(
|
||||
isLoadingState: StateFlow<Boolean>,
|
||||
webDavConfigState: StateFlow<WebDavConfig?>,
|
||||
backupOptions: List<String>,
|
||||
onBackupOptionSelected: (Int) -> Unit,
|
||||
onBackupOptionSelected: (BackupLocation) -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
restoreOptions: List<String>,
|
||||
onRestoreOptionSelected: (Int) -> Unit,
|
||||
onRestoreOptionSelected: (BackupLocation) -> Unit,
|
||||
onCleanupProfiles: () -> Unit,
|
||||
onWebDavSave: (WebDavConfig) -> Unit,
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
@@ -195,12 +197,13 @@ fun BackupScreen(
|
||||
val currentWebDavConfig by webDavConfigState.collectAsState()
|
||||
var showBackupDialog by remember { mutableStateOf(false) }
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var showCleanupDialog by remember { mutableStateOf(false) }
|
||||
var showWebDavDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val webDavSummary = currentWebDavConfig?.baseUrl
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_configuration_backup_restore),
|
||||
@@ -230,6 +233,12 @@ fun BackupScreen(
|
||||
title = stringResource(R.string.title_configuration_restore),
|
||||
onClick = { showRestoreDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_delete_24dp),
|
||||
title = stringResource(R.string.title_profile_storage_cleanup),
|
||||
subtitle = stringResource(R.string.summary_profile_storage_cleanup),
|
||||
onClick = { showCleanupDialog = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_settings_24dp),
|
||||
@@ -237,16 +246,18 @@ fun BackupScreen(
|
||||
subtitle = webDavSummary,
|
||||
onClick = { showWebDavDialog = true }
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
if (showBackupDialog) {
|
||||
SelectListDialog(
|
||||
title = stringResource(R.string.title_configuration_backup),
|
||||
options = backupOptions,
|
||||
onSelected = { index, _ ->
|
||||
options = BackupLocation.entries,
|
||||
optionText = { stringResource(it.labelRes) },
|
||||
onSelected = { location ->
|
||||
showBackupDialog = false
|
||||
onBackupOptionSelected(index)
|
||||
onBackupOptionSelected(location)
|
||||
},
|
||||
onDismiss = { showBackupDialog = false }
|
||||
)
|
||||
@@ -254,14 +265,22 @@ fun BackupScreen(
|
||||
if (showRestoreDialog) {
|
||||
SelectListDialog(
|
||||
title = stringResource(R.string.title_configuration_restore),
|
||||
options = restoreOptions,
|
||||
onSelected = { index, _ ->
|
||||
options = BackupLocation.entries,
|
||||
optionText = { stringResource(it.labelRes) },
|
||||
onSelected = { location ->
|
||||
showRestoreDialog = false
|
||||
onRestoreOptionSelected(index)
|
||||
onRestoreOptionSelected(location)
|
||||
},
|
||||
onDismiss = { showRestoreDialog = false }
|
||||
)
|
||||
}
|
||||
if (showCleanupDialog) {
|
||||
DeleteConfirmDialog(
|
||||
message = stringResource(R.string.message_profile_storage_cleanup),
|
||||
onConfirm = onCleanupProfiles,
|
||||
onDismiss = { showCleanupDialog = false }
|
||||
)
|
||||
}
|
||||
if (showWebDavDialog) {
|
||||
WebDavInputDialog(
|
||||
initialConfig = currentWebDavConfig,
|
||||
@@ -317,7 +336,7 @@ private fun WebDavInputDialog(
|
||||
}
|
||||
},
|
||||
confirmText = stringResource(R.string.menu_item_save_config),
|
||||
dismissText = stringResource(android.R.string.cancel),
|
||||
dismissText = stringResource(R.string.action_cancel),
|
||||
onConfirm = {
|
||||
onSave(
|
||||
WebDavConfig(
|
||||
|
||||
@@ -13,9 +13,11 @@ import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.ui.base.ViewModelEvent
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.ZipUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@@ -37,6 +39,24 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
|
||||
fun cleanupProfileStorage() {
|
||||
launchLoading {
|
||||
try {
|
||||
val removed = withContext(Dispatchers.IO) {
|
||||
MmkvManager.removeOrphanedServerProfiles()
|
||||
}
|
||||
if (removed == null) {
|
||||
toastError(R.string.toast_profile_storage_cleanup_skipped)
|
||||
} else {
|
||||
toastSuccess(getString(R.string.toast_profile_storage_cleanup, removed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to clean up profile storage", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareBackup(cacheDir: File, appName: String) {
|
||||
launchLoading {
|
||||
val ret = backupConfigurationToCache(cacheDir, appName)
|
||||
@@ -165,17 +185,20 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun performRestore(cacheDir: File, zipFile: File): Boolean {
|
||||
val backupDir = cacheDir.absolutePath + "/${System.currentTimeMillis()}"
|
||||
private suspend fun performRestore(cacheDir: File, zipFile: File): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val backupDir = File(cacheDir, "restore_${System.nanoTime()}")
|
||||
try {
|
||||
if (!ZipUtil.unzipToFolder(zipFile, backupDir.absolutePath)) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
|
||||
return false
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir.absolutePath)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
count > 0
|
||||
} finally {
|
||||
backupDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,18 @@
|
||||
package com.v2ray.ang.ui.base
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.ui.compose.AppTheme
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
|
||||
abstract class BaseComponentActivity : ComponentActivity() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
|
||||
}
|
||||
abstract class BaseComponentActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
AppLocaleManager.onActivityCreated(this)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AppTheme {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.v2ray.ang.ui.base
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -27,6 +29,9 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
|
||||
application as AngApplication
|
||||
}
|
||||
|
||||
protected val localizedContext: Context
|
||||
get() = AppLocaleManager.localizedContext(app)
|
||||
|
||||
@Suppress("PropertyName")
|
||||
protected val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
@@ -39,7 +44,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
|
||||
* Send neutral toast event (Resource ID).
|
||||
*/
|
||||
fun toast(resId: Int) {
|
||||
app.toast(resId)
|
||||
localizedContext.toast(resId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +58,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
|
||||
* Send success toast event (Resource ID).
|
||||
*/
|
||||
fun toastSuccess(resId: Int) {
|
||||
app.toastSuccess(resId)
|
||||
localizedContext.toastSuccess(resId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +72,7 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
|
||||
* Send error toast event (Resource ID).
|
||||
*/
|
||||
fun toastError(resId: Int) {
|
||||
app.toastError(resId)
|
||||
localizedContext.toastError(resId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,14 +86,21 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
|
||||
* Get string from resource ID.
|
||||
*/
|
||||
fun getString(resId: Int): String {
|
||||
return app.getString(resId)
|
||||
return localizedContext.getString(resId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted string from resource ID.
|
||||
*/
|
||||
fun getString(resId: Int, vararg formatArgs: Any?): String {
|
||||
return app.getString(resId, *formatArgs)
|
||||
return localizedContext.getString(resId, *formatArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a localized quantity string from a plurals resource.
|
||||
*/
|
||||
fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any): String {
|
||||
return localizedContext.resources.getQuantityString(resId, quantity, *formatArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,4 +125,4 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,18 @@ package com.v2ray.ang.ui.checkupdate
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -26,9 +26,11 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.VersionInfoBlock
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class CheckUpdateActivity : BaseComponentActivity() {
|
||||
@@ -37,6 +39,9 @@ class CheckUpdateActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (savedInstanceState == null) {
|
||||
viewModel.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -57,14 +62,11 @@ fun CheckUpdateScreen(
|
||||
val showUpdateDialog by viewModel.showUpdateDialog.collectAsStateWithLifecycle()
|
||||
val updateResult by viewModel.updateResult.collectAsStateWithLifecycle()
|
||||
|
||||
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.checkForUpdates()
|
||||
}
|
||||
val libVersion = CoreNativeManager.getLibVersion()
|
||||
val versionText = "v${BuildConfig.VERSION_NAME} ($libVersion)"
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.update_check_for_update),
|
||||
@@ -91,6 +93,7 @@ fun CheckUpdateScreen(
|
||||
onClick = { viewModel.checkForUpdates() }
|
||||
)
|
||||
VersionInfoBlock(versionText = versionText)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +102,16 @@ fun CheckUpdateScreen(
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissUpdateDialog() },
|
||||
title = { Text(stringResource(R.string.update_new_version_found, result.latestVersion ?: "")) },
|
||||
text = { Text(result.releaseNotes ?: "") },
|
||||
text = {
|
||||
val scrollState = rememberScrollState()
|
||||
Text(
|
||||
text = result.releaseNotes.orEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
.verticalScrollbar(scrollState)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.dismissUpdateDialog()
|
||||
@@ -110,7 +122,7 @@ fun CheckUpdateScreen(
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.dismissUpdateDialog() }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
|
||||
@@ -42,12 +42,8 @@ class CheckUpdateViewModel(application: Application) : BaseViewModel(application
|
||||
toastSuccess(R.string.update_already_latest_version)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
|
||||
if (e.message == null) {
|
||||
toastError(R.string.toast_failure)
|
||||
} else {
|
||||
toastError(e.message.orEmpty())
|
||||
}
|
||||
LogUtil.e(AppConfig.TAG, "Failed to check for updates", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,4 +51,4 @@ class CheckUpdateViewModel(application: Application) : BaseViewModel(application
|
||||
fun dismissUpdateDialog() {
|
||||
_showUpdateDialog.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,24 @@
|
||||
package com.v2ray.ang.ui.compose
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.Checkbox
|
||||
@@ -45,17 +44,21 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.util.AppIconFetcher
|
||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -92,7 +95,7 @@ fun AppTopBar(
|
||||
IconButton(onClick = if (isSearchActive) onSearchClose else onBackClick) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_back_24dp),
|
||||
contentDescription = "Back"
|
||||
contentDescription = stringResource(R.string.acc_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +113,7 @@ fun AppTopBar(
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = colorFabActive)
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +166,7 @@ fun AppListItem(
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -170,41 +174,26 @@ fun AppListItem(
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (icon != null) {
|
||||
when (icon) {
|
||||
is Bitmap -> {
|
||||
Image(
|
||||
bitmap = icon.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
is Int -> {
|
||||
Image(
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
is Drawable -> {
|
||||
val bitmap = remember(icon) { createBitmapFromDrawable(icon) }
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Box(modifier = Modifier.size(40.dp))
|
||||
}
|
||||
val model = remember(icon, packageName) {
|
||||
if (icon != null) {
|
||||
icon
|
||||
} else {
|
||||
val data = "appicon:$packageName"
|
||||
ImageRequest.Builder(context)
|
||||
.data(data)
|
||||
.fetcherFactory(AppIconFetcher.Factory(context))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
error = painterResource(R.drawable.ic_image_24dp),
|
||||
fallback = painterResource(R.drawable.ic_image_24dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
@@ -225,24 +214,11 @@ fun AppListItem(
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = CheckboxDefaults.colors(checkedColor = colorFabActive)
|
||||
colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colorScheme.secondary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
|
||||
if (drawable is BitmapDrawable) {
|
||||
return drawable.bitmap
|
||||
}
|
||||
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 96
|
||||
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 96
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||
drawable.draw(canvas)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemDivider() {
|
||||
AppDivider(modifier = Modifier.padding(horizontal = 12.dp))
|
||||
@@ -254,6 +230,17 @@ fun AppDivider(modifier: Modifier = Modifier) {
|
||||
HorizontalDivider(modifier = modifier.fillMaxWidth(), thickness = 1.dp, color = color)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationBarsSpacer(modifier: Modifier = Modifier) {
|
||||
Spacer(modifier = modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationBarsBottomPadding(): PaddingValues {
|
||||
val bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
return PaddingValues(bottom = bottom)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionInfoBlock(
|
||||
versionText: String,
|
||||
|
||||
@@ -13,7 +13,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -34,6 +35,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
@@ -42,8 +44,8 @@ import com.v2ray.ang.R
|
||||
fun ConfirmDialog(
|
||||
title: String? = null,
|
||||
message: String,
|
||||
confirmText: String = stringResource(android.R.string.ok),
|
||||
dismissText: String? = stringResource(android.R.string.cancel),
|
||||
confirmText: String = stringResource(R.string.action_ok),
|
||||
dismissText: String? = stringResource(R.string.action_cancel),
|
||||
confirmIcon: @Composable (() -> Unit)? = null,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
@@ -173,7 +175,7 @@ fun QRCodeDialog(
|
||||
text = {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.title_qr_code),
|
||||
contentDescription = stringResource(R.string.acc_qr_code),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
@@ -192,44 +194,46 @@ fun QRCodeDialog(
|
||||
* The selectedOption parameter is used to highlight the selected item only when showRadio is true.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectListDialog(
|
||||
title: String? = null,
|
||||
options: List<String>,
|
||||
selectedOption: String = "",
|
||||
onSelected: (Int, String) -> Unit,
|
||||
fun <T> SelectListDialog(
|
||||
options: List<T>,
|
||||
optionText: @Composable (T) -> String,
|
||||
onSelected: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
title: String? = null,
|
||||
selectedOption: T? = null,
|
||||
showRadio: Boolean = false
|
||||
) {
|
||||
val selectedIndex = if (showRadio) options.indexOf(selectedOption) else -1
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = title?.let { { Text(it) } },
|
||||
text = {
|
||||
LazyColumn {
|
||||
itemsIndexed(options) { index, option ->
|
||||
items(options) { option ->
|
||||
val isSelected = option == selectedOption
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelected(index, option) }
|
||||
.padding(vertical = 4.dp),
|
||||
.then(
|
||||
if (showRadio) Modifier.selectable(
|
||||
selected = isSelected,
|
||||
onClick = { onSelected(option) },
|
||||
role = Role.RadioButton
|
||||
) else Modifier.clickable { onSelected(option) }
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (showRadio) {
|
||||
RadioButton(
|
||||
selected = index == selectedIndex,
|
||||
onClick = { onSelected(index, option) }
|
||||
selected = isSelected,
|
||||
onClick = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = option,
|
||||
text = optionText(option),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = if (!showRadio)
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp)
|
||||
else Modifier
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -238,7 +242,7 @@ fun SelectListDialog(
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
|
||||
@@ -78,6 +78,7 @@ fun FormDropdownField(
|
||||
editable: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
placeholder: String? = null,
|
||||
supportingText: String? = null,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val menuScrollState = rememberScrollState()
|
||||
@@ -104,6 +105,7 @@ fun FormDropdownField(
|
||||
enabled = enabled,
|
||||
label = { Text(label) },
|
||||
placeholder = { if (placeholder != null) Text(placeholder) },
|
||||
supportingText = supportingText?.let { { Text(it) } },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.v2ray.ang.ui.compose
|
||||
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@Composable
|
||||
fun <T> AppDropdownMenuItems(
|
||||
items: List<T>,
|
||||
labelRes: (T) -> Int,
|
||||
onSelected: (T) -> Unit
|
||||
) {
|
||||
items.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(labelRes(item))) },
|
||||
onClick = { onSelected(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.v2ray.ang.ui.compose
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -9,6 +8,8 @@ import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -22,7 +23,10 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.currentStateAsState
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
data class ScrollbarConfig(
|
||||
@@ -43,8 +47,9 @@ private fun <T> rememberScrollbarAlpha(
|
||||
key: Any,
|
||||
config: ScrollbarConfig,
|
||||
position: () -> T,
|
||||
): Animatable<Float, AnimationVector1D> {
|
||||
): State<Float> {
|
||||
val alpha = remember(key) { Animatable(0f) }
|
||||
val lifecycleState = LocalLifecycleOwner.current.lifecycle.currentStateAsState()
|
||||
LaunchedEffect(key, config.fadeOutDurationMs, config.fadeAnimDurationMs) {
|
||||
snapshotFlow(position).collectLatest {
|
||||
alpha.snapTo(1f)
|
||||
@@ -52,7 +57,11 @@ private fun <T> rememberScrollbarAlpha(
|
||||
alpha.animateTo(0f, tween(config.fadeAnimDurationMs))
|
||||
}
|
||||
}
|
||||
return alpha
|
||||
return remember(alpha, lifecycleState) {
|
||||
derivedStateOf {
|
||||
if (lifecycleState.value == Lifecycle.State.RESUMED) alpha.value else 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -35,7 +35,7 @@ fun PreferenceGroupHeader(title: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = colorFabActive,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
|
||||
@@ -59,13 +59,13 @@ fun CollapsiblePreferenceGroupHeader(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = colorFabActive,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_expand_more_24dp),
|
||||
contentDescription = null,
|
||||
tint = colorFabActive,
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(if (expanded) 180f else 0f)
|
||||
@@ -164,8 +164,8 @@ fun SettingsEditItem(
|
||||
)
|
||||
),
|
||||
onFieldChange = { _, v -> text = v },
|
||||
confirmText = stringResource(android.R.string.ok),
|
||||
dismissText = stringResource(android.R.string.cancel),
|
||||
confirmText = stringResource(R.string.action_ok),
|
||||
dismissText = stringResource(R.string.action_cancel),
|
||||
onConfirm = { showDialog = false; onValueChanged(text) },
|
||||
onDismiss = { showDialog = false }
|
||||
)
|
||||
@@ -184,8 +184,9 @@ fun SettingsListItem(
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val selectedIndex = values.indexOf(selectedValue).let { if (it >= 0) it else 0 }
|
||||
val summary = entries.getOrNull(selectedIndex).orEmpty()
|
||||
val options = entries.zip(values)
|
||||
val selectedOption = options.find { it.second == selectedValue } ?: options.firstOrNull()
|
||||
val summary = selectedOption?.first.orEmpty()
|
||||
|
||||
SettingsItemRow(
|
||||
icon = icon,
|
||||
@@ -201,11 +202,12 @@ fun SettingsListItem(
|
||||
if (showDialog) {
|
||||
SelectListDialog(
|
||||
title = title,
|
||||
options = entries,
|
||||
selectedOption = summary,
|
||||
onSelected = { index, _ ->
|
||||
options = options,
|
||||
optionText = { it.first },
|
||||
selectedOption = selectedOption,
|
||||
onSelected = { option ->
|
||||
showDialog = false
|
||||
values.getOrNull(index)?.let(onSelected)
|
||||
onSelected(option.second)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
showRadio = true
|
||||
|
||||
@@ -31,13 +31,12 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
enum class ToastType {
|
||||
NORMAL, SUCCESS, ERROR, INFO
|
||||
@@ -88,7 +87,7 @@ class AppSnackbarController(
|
||||
if (currentShowTime != 0L) {
|
||||
val elapsed = System.currentTimeMillis() - currentShowTime
|
||||
if (elapsed < SnackbarThrottleMs) {
|
||||
delay((SnackbarThrottleMs - elapsed).milliseconds)
|
||||
delay((SnackbarThrottleMs - elapsed))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.v2ray.ang.ui.compose
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -15,6 +18,7 @@ import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
@@ -122,14 +126,26 @@ object ThemeManager {
|
||||
)
|
||||
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||
|
||||
private val _dynamicColorEnabled = MutableStateFlow(
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_COLOR, true)
|
||||
)
|
||||
val dynamicColorEnabled: StateFlow<Boolean> = _dynamicColorEnabled.asStateFlow()
|
||||
|
||||
fun setThemeMode(mode: String) {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_UI_MODE_NIGHT, mode)
|
||||
_themeMode.value = mode
|
||||
}
|
||||
|
||||
fun setDynamicColorEnabled(enabled: Boolean) {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_DYNAMIC_COLOR, enabled)
|
||||
_dynamicColorEnabled.value = enabled
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
_themeMode.value =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0") ?: "0"
|
||||
_dynamicColorEnabled.value =
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_COLOR, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +166,16 @@ fun AppTheme(
|
||||
darkTheme: Boolean = resolveDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColor else LightColor
|
||||
val dynamicColor by ThemeManager.dynamicColorEnabled.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColor
|
||||
else -> LightColor
|
||||
}
|
||||
val snackbarController = rememberAppSnackbarController()
|
||||
|
||||
val view = LocalView.current
|
||||
|
||||
@@ -8,12 +8,14 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -21,7 +23,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,12 +38,15 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -91,8 +95,9 @@ class LogcatActivity : BaseComponentActivity() {
|
||||
|
||||
uri to logFile.name
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share Logcat", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
toast(e.localizedMessage ?: e.toString())
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
@@ -127,7 +132,7 @@ fun LogcatScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val logs by viewModel.filteredLogs.collectAsStateWithLifecycle()
|
||||
val logs by viewModel.logEntries.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
@@ -136,7 +141,7 @@ fun LogcatScreen(
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_logcat),
|
||||
@@ -159,20 +164,20 @@ fun LogcatScreen(
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_search_24dp),
|
||||
contentDescription = "filter"
|
||||
contentDescription = stringResource(R.string.acc_search)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.copyLogcat() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_copy),
|
||||
contentDescription = stringResource(R.string.logcat_copy)
|
||||
contentDescription = stringResource(R.string.acc_copy_log)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onShareLogcat() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_share_24dp),
|
||||
contentDescription = stringResource(R.string.logcat_share)
|
||||
contentDescription = stringResource(R.string.acc_share_log)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
@@ -180,19 +185,20 @@ fun LogcatScreen(
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_delete_24dp),
|
||||
contentDescription = stringResource(R.string.logcat_clear)
|
||||
contentDescription = stringResource(R.string.acc_clear_log)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = {
|
||||
viewModel.loadLogcat()
|
||||
}) {
|
||||
FloatingActionButton(
|
||||
onClick = { viewModel.loadLogcat() },
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_restore_24dp),
|
||||
contentDescription = stringResource(R.string.pull_down_to_refresh)
|
||||
contentDescription = stringResource(R.string.acc_refresh)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -206,10 +212,11 @@ fun LogcatScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(items = logs, key = { index, _ -> index }) { _, log ->
|
||||
LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) })
|
||||
items(items = logs, key = { it.key }) { log ->
|
||||
LogcatItem(log = log.text, onLongClick = { Utils.setClipboard(context, log.text) })
|
||||
ItemDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package com.v2ray.ang.ui.logcat
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.io.IOException
|
||||
|
||||
class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
@@ -17,6 +23,10 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
private val _filteredLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
|
||||
val logEntries: StateFlow<List<LogcatEntry>> = filteredLogs
|
||||
.map(::createLogcatEntries)
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun loadLogcat() {
|
||||
launchLoading {
|
||||
@@ -73,4 +83,17 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
|
||||
logsetsAll.filter { it.contains(currentFilter) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LogcatEntry(val key: String, val text: String)
|
||||
|
||||
internal fun createLogcatEntries(logs: List<String>): List<LogcatEntry> {
|
||||
val occurrences = HashMap<String, Int>()
|
||||
// Identical lines are valid. Count oldest-first so filtering other lines or
|
||||
// prepending newer entries does not change the keys of existing occurrences.
|
||||
return logs.asReversed().map { line ->
|
||||
val occurrence = occurrences.getOrDefault(line, 0)
|
||||
occurrences[line] = occurrence + 1
|
||||
LogcatEntry("$occurrence:$line", line)
|
||||
}.asReversed()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -74,10 +75,10 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val restartService = data.getBooleanExtra(
|
||||
ProfileEditorResult.EXTRA_RESTART_SERVICE, false
|
||||
)
|
||||
val selectedProfileSaved = action == ProfileEditorResult.ACTION_SAVED &&
|
||||
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
|
||||
mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService && mainViewModel.uiState.value.isRunning) {
|
||||
restartV2Ray()
|
||||
}
|
||||
if (restartService || selectedProfileSaved) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
private val settingsActivityLauncher =
|
||||
@@ -86,7 +87,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val refreshGroups = SettingsChangeManager.consumeSetupGroupTab()
|
||||
mainViewModel.refreshUiSettings()
|
||||
if (refreshGroups) mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService && mainViewModel.uiState.value.isRunning) restartV2Ray()
|
||||
if (restartService) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -98,6 +99,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
BackHandler { moveTaskToBack(false) }
|
||||
MainScreen(
|
||||
mainViewModel = mainViewModel,
|
||||
onAction = { action ->
|
||||
@@ -108,7 +110,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
MainAction.ImportClipboard -> importClipboard()
|
||||
MainAction.ImportConfigLocal -> importConfigLocal()
|
||||
is MainAction.ImportManually -> importManually(action.type)
|
||||
MainAction.RestartService -> restartV2Ray()
|
||||
MainAction.RestartService -> LauncherManager.restartServiceOrStart(this, ::requestServiceStart)
|
||||
MainAction.LocateSelectedServer -> mainViewModel.triggerLocateSelectedServer()
|
||||
is MainAction.SelectServer -> setSelectServer(action.guid)
|
||||
is MainAction.EditServer -> editServer(action.guid, action.profile)
|
||||
@@ -134,26 +136,24 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateTo(destination: String) {
|
||||
private fun navigateTo(destination: MainDestination) {
|
||||
val intent = when (destination) {
|
||||
"sub_setting" -> Intent(this, SubSettingActivity::class.java)
|
||||
"per_app_proxy" -> Intent(this, PerAppProxyActivity::class.java)
|
||||
"routing_setting" -> Intent(this, RoutingSettingActivity::class.java)
|
||||
"user_asset" -> Intent(this, UserAssetActivity::class.java)
|
||||
"settings" -> Intent(this, SettingsActivity::class.java)
|
||||
"logcat" -> Intent(this, LogcatActivity::class.java)
|
||||
"check_update" -> Intent(this, CheckUpdateActivity::class.java)
|
||||
"backup_restore" -> Intent(this, BackupActivity::class.java)
|
||||
"about" -> Intent(this, AboutActivity::class.java)
|
||||
"promotion" -> {
|
||||
MainDestination.Subscriptions -> Intent(this, SubSettingActivity::class.java)
|
||||
MainDestination.PerAppProxy -> Intent(this, PerAppProxyActivity::class.java)
|
||||
MainDestination.Routing -> Intent(this, RoutingSettingActivity::class.java)
|
||||
MainDestination.UserAssets -> Intent(this, UserAssetActivity::class.java)
|
||||
MainDestination.Settings -> Intent(this, SettingsActivity::class.java)
|
||||
MainDestination.Logcat -> Intent(this, LogcatActivity::class.java)
|
||||
MainDestination.CheckUpdate -> Intent(this, CheckUpdateActivity::class.java)
|
||||
MainDestination.BackupRestore -> Intent(this, BackupActivity::class.java)
|
||||
MainDestination.About -> Intent(this, AboutActivity::class.java)
|
||||
MainDestination.Promotion -> {
|
||||
Utils.openUri(
|
||||
this,
|
||||
"${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
else -> return
|
||||
}
|
||||
settingsActivityLauncher.launch(intent)
|
||||
}
|
||||
@@ -161,14 +161,20 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
private fun handleFabAction() {
|
||||
if (mainViewModel.uiState.value.isRunning) {
|
||||
LauncherManager.stopService(this)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
|
||||
} else {
|
||||
startV2Ray()
|
||||
requestServiceStart()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestServiceStart() {
|
||||
if (!SettingsManager.isVpnMode()) {
|
||||
startV2Ray()
|
||||
return
|
||||
}
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
|
||||
}
|
||||
|
||||
private fun handleLayoutTestClick() {
|
||||
if (mainViewModel.uiState.value.isRunning) {
|
||||
mainViewModel.testCurrentServerRealPing()
|
||||
@@ -188,14 +194,6 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
LauncherManager.startService(this)
|
||||
}
|
||||
|
||||
private fun restartV2Ray() {
|
||||
if (mainViewModel.uiState.value.isRunning) LauncherManager.stopService(this)
|
||||
lifecycleScope.launch {
|
||||
kotlinx.coroutines.delay(500)
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun importManually(createConfigType: Int) {
|
||||
val intent = when (createConfigType) {
|
||||
EConfigType.POLICYGROUP.value -> Intent(this, ServerGroupActivity::class.java)
|
||||
@@ -275,12 +273,12 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val selected = mainViewModel.uiState.value.selectedGuid
|
||||
if (guid != selected) {
|
||||
mainViewModel.updateSelectedGuid(guid)
|
||||
if (mainViewModel.uiState.value.isRunning) restartV2Ray()
|
||||
LauncherManager.restartService(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
moveTaskToBack(false)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -17,13 +18,15 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.ui.compose.AppDivider
|
||||
@@ -39,26 +42,29 @@ fun MainBottomBar(
|
||||
onAction: (MainAction) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable(onClick = { onAction(MainAction.TestCurrentServer) })
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
) {
|
||||
AppDivider()
|
||||
Surface(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.height(64.dp)
|
||||
.clickable(onClick = { onAction(MainAction.TestCurrentServer) }),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 0.dp
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = displayText, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = displayText
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
@@ -75,7 +81,9 @@ fun MainBottomBar(
|
||||
Icon(
|
||||
painter = if (isRunning) painterResource(R.drawable.ic_stop_24dp)
|
||||
else painterResource(R.drawable.ic_play_24dp),
|
||||
contentDescription = if (isRunning) "Stop" else "Start",
|
||||
contentDescription = stringResource(
|
||||
if (isRunning) R.string.acc_stop else R.string.acc_start
|
||||
),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import com.v2ray.ang.dto.ConnectionTestResult
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.LocateTarget
|
||||
|
||||
/** Locale-neutral state formatted only when it reaches the main UI. */
|
||||
sealed interface MainStatus {
|
||||
data object Disconnected : MainStatus
|
||||
data object Connected : MainStatus
|
||||
data object Testing : MainStatus
|
||||
data class TestProgress(val progress: String) : MainStatus
|
||||
data class ConnectionTest(val result: ConnectionTestResult) : MainStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* Main UI state
|
||||
*/
|
||||
@@ -12,7 +22,7 @@ data class MainUiState(
|
||||
val selectedGuid: String? = null,
|
||||
val isRunning: Boolean = false,
|
||||
val isTesting: Boolean = false,
|
||||
val statusText: String = "",
|
||||
val status: MainStatus = MainStatus.Disconnected,
|
||||
val locateTarget: LocateTarget? = null,
|
||||
val confirmRemove: Boolean = false,
|
||||
val doubleColumnDisplay: Boolean = false,
|
||||
@@ -56,5 +66,5 @@ sealed interface MainAction {
|
||||
|
||||
data class ImportBatchConfig(val configText: String) : MainAction
|
||||
|
||||
data class LocateHandled(val target: LocateTarget) : MainAction
|
||||
data object LocateHandled : MainAction
|
||||
}
|
||||
@@ -1,62 +1,86 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.ui.compose.AppDivider
|
||||
import com.v2ray.ang.ui.compose.LocalDarkTheme
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
|
||||
enum class MainDestination(@DrawableRes val iconRes: Int, @StringRes val labelRes: Int) {
|
||||
Subscriptions(R.drawable.ic_subscriptions_24dp, R.string.title_sub_setting),
|
||||
PerAppProxy(R.drawable.ic_per_apps_24dp, R.string.per_app_proxy_settings),
|
||||
Routing(R.drawable.ic_routing_24dp, R.string.routing_settings_title),
|
||||
UserAssets(R.drawable.ic_file_24dp, R.string.title_user_asset_setting),
|
||||
Settings(R.drawable.ic_settings_24dp, R.string.title_settings),
|
||||
Promotion(R.drawable.ic_promotion_24dp, R.string.title_pref_promotion),
|
||||
Logcat(R.drawable.ic_logcat_24dp, R.string.title_logcat),
|
||||
CheckUpdate(R.drawable.ic_check_update_24dp, R.string.update_check_for_update),
|
||||
BackupRestore(R.drawable.ic_restore_24dp, R.string.title_configuration_backup_restore),
|
||||
About(R.drawable.ic_about_24dp, R.string.title_about)
|
||||
}
|
||||
|
||||
private val primaryDrawerItems = listOf(
|
||||
MainDestination.Subscriptions,
|
||||
MainDestination.PerAppProxy,
|
||||
MainDestination.Routing,
|
||||
MainDestination.UserAssets,
|
||||
MainDestination.Settings
|
||||
)
|
||||
|
||||
private val drawerItems = primaryDrawerItems + listOf(
|
||||
MainDestination.Promotion,
|
||||
MainDestination.Logcat,
|
||||
MainDestination.CheckUpdate,
|
||||
MainDestination.BackupRestore,
|
||||
MainDestination.About
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MainDrawerContent(onNavigate: (String) -> Unit) {
|
||||
fun MainDrawerContent(drawerState: DrawerState, onNavigate: (MainDestination) -> Unit) {
|
||||
val drawerScrollState = rememberScrollState()
|
||||
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.navigationBarsPadding(),
|
||||
drawerState = drawerState,
|
||||
modifier = Modifier.fillMaxWidth(0.75f),
|
||||
drawerContainerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(drawerScrollState)
|
||||
.verticalScrollbar(drawerScrollState)
|
||||
.padding(bottom = 80.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.height(180.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -65,92 +89,34 @@ fun MainDrawerContent(onNavigate: (String) -> Unit) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
val isDarkTheme = LocalDarkTheme.current
|
||||
Image(
|
||||
painter = painterResource(R.mipmap.ic_launcher_foreground),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(120.dp),
|
||||
colorFilter = if (isDarkTheme) {
|
||||
ColorFilter.tint(Color.White, BlendMode.SrcIn)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily(Font(R.font.montserrat_thin)),
|
||||
fontWeight = FontWeight.Thin
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
DrawerMenuGroup(
|
||||
items = listOf(
|
||||
DrawerMenuItemData(R.drawable.ic_subscriptions_24dp, R.string.title_sub_setting, "sub_setting"),
|
||||
DrawerMenuItemData(R.drawable.ic_per_apps_24dp, R.string.per_app_proxy_settings, "per_app_proxy"),
|
||||
DrawerMenuItemData(R.drawable.ic_routing_24dp, R.string.routing_settings_title, "routing_setting"),
|
||||
DrawerMenuItemData(R.drawable.ic_file_24dp, R.string.title_user_asset_setting, "user_asset"),
|
||||
DrawerMenuItemData(R.drawable.ic_settings_24dp, R.string.title_settings, "settings")
|
||||
),
|
||||
onNavigate = onNavigate
|
||||
)
|
||||
AppDivider()
|
||||
DrawerMenuGroup(
|
||||
items = listOf(
|
||||
DrawerMenuItemData(R.drawable.ic_promotion_24dp, R.string.title_pref_promotion, "promotion"),
|
||||
DrawerMenuItemData(R.drawable.ic_logcat_24dp, R.string.title_logcat, "logcat"),
|
||||
DrawerMenuItemData(R.drawable.ic_check_update_24dp, R.string.update_check_for_update, "check_update"),
|
||||
DrawerMenuItemData(R.drawable.ic_restore_24dp, R.string.title_configuration_backup_restore, "backup_restore"),
|
||||
DrawerMenuItemData(R.drawable.ic_about_24dp, R.string.title_about, "about")
|
||||
),
|
||||
onNavigate = onNavigate
|
||||
)
|
||||
drawerItems.forEachIndexed { index, item ->
|
||||
if (index == primaryDrawerItems.size) AppDivider()
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(item.labelRes)) },
|
||||
selected = false,
|
||||
onClick = { onNavigate(item) },
|
||||
icon = { Icon(painterResource(item.iconRes), contentDescription = null) },
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DrawerMenuItemData(
|
||||
val iconRes: Int,
|
||||
val labelRes: Int,
|
||||
val route: String
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun DrawerMenuGroup(
|
||||
items: List<DrawerMenuItemData>,
|
||||
onNavigate: (String) -> Unit
|
||||
) {
|
||||
items.forEach { item ->
|
||||
DrawerMenuItem(
|
||||
icon = painterResource(item.iconRes),
|
||||
label = stringResource(item.labelRes),
|
||||
onClick = { onNavigate(item.route) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DrawerMenuItem(
|
||||
icon: Painter,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.background(
|
||||
if (selected) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)
|
||||
else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -11,14 +13,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Composable
|
||||
@@ -29,32 +29,28 @@ fun GroupTabBar(
|
||||
onTabClick: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = selectedTabIndex.coerceIn(0, groups.lastIndex),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedIndex,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
|
||||
edgePadding = 16.dp,
|
||||
minTabWidth = 56.dp,
|
||||
indicator = {
|
||||
TabRowDefaults.PrimaryIndicator(
|
||||
modifier = Modifier
|
||||
.tabIndicatorOffset(
|
||||
selectedTabIndex = selectedTabIndex.coerceIn(0, groups.lastIndex),
|
||||
matchContentSize = true
|
||||
)
|
||||
.clip(RoundedCornerShape(3.dp)),
|
||||
width = Dp.Unspecified,
|
||||
color = colorFabActive
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.SecondaryIndicator(
|
||||
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
},
|
||||
divider = {}
|
||||
}
|
||||
) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
val serverFlow = remember(group.id, mainViewModel) {
|
||||
mainViewModel.serversForGroup(group.id)
|
||||
}
|
||||
GroupTabItem(
|
||||
group = group,
|
||||
selected = index == selectedTabIndex,
|
||||
serverFlowProvider = { mainViewModel.serversForGroup(group.id) },
|
||||
selected = index == selectedIndex,
|
||||
serverFlow = serverFlow,
|
||||
onClick = { onTabClick(index) }
|
||||
)
|
||||
}
|
||||
@@ -65,20 +61,23 @@ fun GroupTabBar(
|
||||
private fun GroupTabItem(
|
||||
group: GroupMapItem,
|
||||
selected: Boolean,
|
||||
serverFlowProvider: () -> StateFlow<List<ServersCache>>,
|
||||
serverFlow: StateFlow<List<ServersCache>>,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val serverFlow = remember(group.id) { serverFlowProvider() }
|
||||
val servers by serverFlow.collectAsStateWithLifecycle()
|
||||
val text = if (group.id.isEmpty()) {
|
||||
group.remarks
|
||||
} else {
|
||||
"${group.remarks} (${servers.size})"
|
||||
}
|
||||
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.widthIn(min = 56.dp)
|
||||
.heightIn(min = 48.dp),
|
||||
text = {
|
||||
val text = if (group.id.isEmpty()) {
|
||||
group.remarks
|
||||
} else {
|
||||
"${group.remarks} (${servers.size})"
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
|
||||
private enum class ImportMenuAction(@StringRes val labelRes: Int, val action: MainAction) {
|
||||
QRCode(R.string.menu_item_import_config_qrcode, MainAction.ImportQRcode),
|
||||
Clipboard(R.string.menu_item_import_config_clipboard, MainAction.ImportClipboard),
|
||||
LocalFile(R.string.menu_item_import_config_local, MainAction.ImportConfigLocal),
|
||||
PolicyGroup(R.string.menu_item_import_config_policy_group, MainAction.ImportManually(EConfigType.POLICYGROUP.value)),
|
||||
ProxyChain(R.string.menu_item_import_config_proxy_chain, MainAction.ImportManually(EConfigType.PROXYCHAIN.value)),
|
||||
Vmess(R.string.menu_item_import_config_manually_vmess, MainAction.ImportManually(EConfigType.VMESS.value)),
|
||||
Vless(R.string.menu_item_import_config_manually_vless, MainAction.ImportManually(EConfigType.VLESS.value)),
|
||||
Shadowsocks(R.string.menu_item_import_config_manually_ss, MainAction.ImportManually(EConfigType.SHADOWSOCKS.value)),
|
||||
Socks(R.string.menu_item_import_config_manually_socks, MainAction.ImportManually(EConfigType.SOCKS.value)),
|
||||
Http(R.string.menu_item_import_config_manually_http, MainAction.ImportManually(EConfigType.HTTP.value)),
|
||||
Trojan(R.string.menu_item_import_config_manually_trojan, MainAction.ImportManually(EConfigType.TROJAN.value)),
|
||||
WireGuard(R.string.menu_item_import_config_manually_wireguard, MainAction.ImportManually(EConfigType.WIREGUARD.value)),
|
||||
Hysteria2(R.string.menu_item_import_config_manually_hysteria2, MainAction.ImportManually(EConfigType.HYSTERIA2.value))
|
||||
}
|
||||
|
||||
enum class MainMoreMenuAction(@StringRes val labelRes: Int) {
|
||||
RestartService(R.string.title_service_restart),
|
||||
DeleteAll(R.string.title_del_all_config),
|
||||
DeleteDuplicate(R.string.title_del_duplicate_config),
|
||||
DeleteInvalid(R.string.title_del_invalid_config),
|
||||
ExportAll(R.string.title_export_all),
|
||||
LocateSelected(R.string.title_locate_selected_config),
|
||||
SortByTestResults(R.string.title_sort_by_test_results),
|
||||
TestAll(R.string.title_ping_all_server),
|
||||
TestAllRealPing(R.string.title_real_ping_all_server),
|
||||
UpdateSubscriptions(R.string.title_sub_update)
|
||||
}
|
||||
|
||||
internal enum class ServerMenuAction(
|
||||
@StringRes val labelRes: Int,
|
||||
val isShareAction: Boolean,
|
||||
@@ -31,111 +59,18 @@ internal fun serverMenuActions(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImportMenuContent(
|
||||
onAction: (MainAction) -> Unit
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_qrcode)) },
|
||||
onClick = { onAction(MainAction.ImportQRcode) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_clipboard)) },
|
||||
onClick = { onAction(MainAction.ImportClipboard) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_local)) },
|
||||
onClick = { onAction(MainAction.ImportConfigLocal) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_policy_group)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.POLICYGROUP.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_proxy_chain)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.PROXYCHAIN.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_manually_vmess)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.VMESS.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_manually_vless)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.VLESS.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_manually_ss)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.SHADOWSOCKS.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_manually_socks)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.SOCKS.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_manually_http)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.HTTP.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_manually_trojan)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.TROJAN.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_manually_wireguard)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.WIREGUARD.value)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_config_manually_hysteria2)) },
|
||||
onClick = { onAction(MainAction.ImportManually(EConfigType.HYSTERIA2.value)) }
|
||||
)
|
||||
}
|
||||
fun ImportMenuContent(onAction: (MainAction) -> Unit) = AppDropdownMenuItems(
|
||||
items = ImportMenuAction.entries,
|
||||
labelRes = { it.labelRes },
|
||||
onSelected = { onAction(it.action) }
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MoreMenuContent(
|
||||
onAction: (MainAction) -> Unit,
|
||||
onDelAllConfig: () -> Unit,
|
||||
onDelDuplicateConfig: () -> Unit,
|
||||
onDelInvalidConfig: () -> Unit
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_service_restart)) },
|
||||
onClick = { onAction(MainAction.RestartService) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_del_all_config)) },
|
||||
onClick = onDelAllConfig
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_del_duplicate_config)) },
|
||||
onClick = onDelDuplicateConfig
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_del_invalid_config)) },
|
||||
onClick = onDelInvalidConfig
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_export_all)) },
|
||||
onClick = { onAction(MainAction.ExportAll) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_locate_selected_config)) },
|
||||
onClick = { onAction(MainAction.LocateSelectedServer) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_sort_by_test_results)) },
|
||||
onClick = { onAction(MainAction.SortByTestResults) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_ping_all_server)) },
|
||||
onClick = { onAction(MainAction.TestAllServers) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_real_ping_all_server)) },
|
||||
onClick = { onAction(MainAction.TestRealAllServers) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.title_sub_update)) },
|
||||
onClick = { onAction(MainAction.UpdateSubscriptions) }
|
||||
)
|
||||
}
|
||||
fun MoreMenuContent(onSelected: (MainMoreMenuAction) -> Unit) = AppDropdownMenuItems(
|
||||
items = MainMoreMenuAction.entries,
|
||||
labelRes = { it.labelRes },
|
||||
onSelected = onSelected
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ShareMethodDialog(
|
||||
@@ -151,10 +86,11 @@ fun ShareMethodDialog(
|
||||
includeManagementActions = more,
|
||||
)
|
||||
SelectListDialog(
|
||||
options = menuActions.map { stringResource(it.labelRes) },
|
||||
onSelected = { index, _ ->
|
||||
options = menuActions,
|
||||
optionText = { stringResource(it.labelRes) },
|
||||
onSelected = { action ->
|
||||
onDismiss()
|
||||
when (menuActions[index]) {
|
||||
when (action) {
|
||||
ServerMenuAction.ShareQRCode -> onAction(MainAction.ShareQRCode(guid))
|
||||
ServerMenuAction.ShareClipboard -> onAction(MainAction.ShareClipboard(guid))
|
||||
ServerMenuAction.ShareFullContent -> onAction(MainAction.ShareFullContent(guid))
|
||||
|
||||
@@ -8,13 +8,16 @@ import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.ConnectionTestResult
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
@@ -31,6 +34,9 @@ class MainRepository(
|
||||
private val app: AngApplication
|
||||
) : MainDataSource {
|
||||
|
||||
private val localizedContext: Context
|
||||
get() = AppLocaleManager.localizedContext(app)
|
||||
|
||||
private val closed = AtomicBoolean(false)
|
||||
|
||||
private val _mainServiceEvent = MutableSharedFlow<MainServiceEvent>(
|
||||
@@ -48,14 +54,12 @@ class MainRepository(
|
||||
AppConfig.MSG_STATE_RUNNING -> MainServiceEvent.StateRunning
|
||||
AppConfig.MSG_STATE_NOT_RUNNING -> MainServiceEvent.StateNotRunning
|
||||
AppConfig.MSG_STATE_START_SUCCESS -> MainServiceEvent.StateStartSuccess
|
||||
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure(
|
||||
safeIntent.getStringExtra("content").orEmpty()
|
||||
)
|
||||
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure
|
||||
|
||||
AppConfig.MSG_STATE_STOP_SUCCESS -> MainServiceEvent.StateStopSuccess
|
||||
AppConfig.MSG_MEASURE_DELAY_SUCCESS -> MainServiceEvent.MeasureDelaySuccess(
|
||||
safeIntent.getStringExtra("content").orEmpty()
|
||||
)
|
||||
AppConfig.MSG_MEASURE_DELAY_RESULT -> safeIntent
|
||||
.serializable<ConnectionTestResult>("content")
|
||||
?.let { MainServiceEvent.MeasureDelayResult(it) }
|
||||
|
||||
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> MainServiceEvent.MeasureConfigSuccess
|
||||
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> MainServiceEvent.MeasureConfigNotify(
|
||||
@@ -116,9 +120,10 @@ class MainRepository(
|
||||
override fun isGroupAllDisplayEnabled(): Boolean =
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)
|
||||
|
||||
override fun getString(resId: Int): String = app.getString(resId)
|
||||
override fun getString(resId: Int): String = localizedContext.getString(resId)
|
||||
|
||||
override fun getString(resId: Int, vararg formatArgs: Any): String = app.getString(resId, *formatArgs)
|
||||
override fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||
localizedContext.getString(resId, *formatArgs)
|
||||
|
||||
override fun getSubscriptions(): List<SubscriptionCache> {
|
||||
val result = mutableListOf<SubscriptionCache>()
|
||||
@@ -126,7 +131,7 @@ class MainRepository(
|
||||
result += SubscriptionCache(
|
||||
guid = "",
|
||||
subscription = SubscriptionItem().apply {
|
||||
remarks = app.getString(R.string.filter_config_all)
|
||||
remarks = localizedContext.getString(R.string.filter_config_all)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
@@ -31,22 +30,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.ui.compose.LocalDarkTheme
|
||||
import com.v2ray.ang.ui.compose.QRCodeDialog
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
mainViewModel: MainViewModel,
|
||||
onAction: (MainAction) -> Unit,
|
||||
onNavigate: (String) -> Unit,
|
||||
onNavigate: (MainDestination) -> Unit,
|
||||
) {
|
||||
val uiState by mainViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val groups = uiState.groups
|
||||
val isLoading by mainViewModel.isLoading.collectAsStateWithLifecycle()
|
||||
val isRunning = uiState.isRunning
|
||||
val displayText = uiState.statusText
|
||||
val displayText = mainViewModel.formatStatus(uiState.status)
|
||||
val selectedGuid = uiState.selectedGuid
|
||||
val doubleColumnDisplay = uiState.doubleColumnDisplay
|
||||
val confirmRemove = uiState.confirmRemove
|
||||
@@ -75,16 +72,12 @@ fun MainScreen(
|
||||
val lazyListStates = remember { mutableStateMapOf<String, LazyListState>() }
|
||||
val lazyGridStates = remember { mutableStateMapOf<String, LazyGridState>() }
|
||||
|
||||
var locateInProgress by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(groups) {
|
||||
val validGroupIds = groups.map { it.id }.toSet()
|
||||
lazyListStates.keys.retainAll(validGroupIds)
|
||||
lazyGridStates.keys.retainAll(validGroupIds)
|
||||
}
|
||||
|
||||
val latestDoubleColumnDisplay by rememberUpdatedState(doubleColumnDisplay)
|
||||
|
||||
LaunchedEffect(groups, uiState.selectedGroupId) {
|
||||
if (groups.isEmpty()) return@LaunchedEffect
|
||||
val selectedIndex = groups.indexOfFirst { it.id == uiState.selectedGroupId }
|
||||
@@ -95,68 +88,18 @@ fun MainScreen(
|
||||
}
|
||||
|
||||
val latestGroups by rememberUpdatedState(groups)
|
||||
val latestLocateInProgress by rememberUpdatedState(locateInProgress)
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }
|
||||
.distinctUntilChanged()
|
||||
.collect { page ->
|
||||
val currentGroups = latestGroups
|
||||
if (!latestLocateInProgress && page in currentGroups.indices) {
|
||||
if (page in currentGroups.indices) {
|
||||
onAction(MainAction.SelectGroup(currentGroups[page].id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.locateTarget) {
|
||||
val target = uiState.locateTarget ?: return@LaunchedEffect
|
||||
if (target.groupIndex !in 0 until pagerState.pageCount) {
|
||||
mainViewModel.onAction(MainAction.LocateHandled(target))
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
locateInProgress = true
|
||||
try {
|
||||
if (pagerState.settledPage != target.groupIndex) {
|
||||
pagerState.navigateToPageOptimized(
|
||||
targetPage = target.groupIndex,
|
||||
animateAdjacentPage = false
|
||||
)
|
||||
}
|
||||
onAction(MainAction.SelectGroup(target.groupId))
|
||||
|
||||
repeat(10) {
|
||||
val ready = if (latestDoubleColumnDisplay) {
|
||||
lazyGridStates[target.groupId] != null
|
||||
} else {
|
||||
lazyListStates[target.groupId] != null
|
||||
}
|
||||
if (ready) return@repeat
|
||||
delay(16L)
|
||||
}
|
||||
|
||||
if (latestDoubleColumnDisplay) {
|
||||
lazyGridStates[target.groupId]?.let { gridState ->
|
||||
gridState.scrollToItem(
|
||||
index = target.itemPosition,
|
||||
scrollOffset = -gridState.layoutInfo.viewportSize.height / 3
|
||||
)
|
||||
}
|
||||
} else {
|
||||
lazyListStates[target.groupId]?.let { listState ->
|
||||
listState.scrollToItem(
|
||||
index = target.itemPosition,
|
||||
scrollOffset = -listState.layoutInfo.viewportSize.height / 3
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
delay(32L)
|
||||
locateInProgress = false
|
||||
mainViewModel.onAction(MainAction.LocateHandled(target))
|
||||
}
|
||||
}
|
||||
|
||||
MainDialogs(
|
||||
showDelAllConfirm = showDelAllConfirm,
|
||||
onDismissDelAll = { showDelAllConfirm = false },
|
||||
@@ -191,6 +134,7 @@ fun MainScreen(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
MainDrawerContent(
|
||||
drawerState = drawerState,
|
||||
onNavigate = { route ->
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigate(route)
|
||||
@@ -217,9 +161,20 @@ fun MainScreen(
|
||||
onSearchToggle = { show: Boolean -> showSearch = show },
|
||||
onMenuClick = { scope.launch { drawerState.open() } },
|
||||
onAction = onAction,
|
||||
onDelAllConfig = { showDelAllConfirm = true },
|
||||
onDelDuplicateConfig = { showDelDuplicateConfirm = true },
|
||||
onDelInvalidConfig = { showDelInvalidConfirm = true }
|
||||
onMoreMenuAction = { action ->
|
||||
when (action) {
|
||||
MainMoreMenuAction.RestartService -> onAction(MainAction.RestartService)
|
||||
MainMoreMenuAction.DeleteAll -> showDelAllConfirm = true
|
||||
MainMoreMenuAction.DeleteDuplicate -> showDelDuplicateConfirm = true
|
||||
MainMoreMenuAction.DeleteInvalid -> showDelInvalidConfirm = true
|
||||
MainMoreMenuAction.ExportAll -> onAction(MainAction.ExportAll)
|
||||
MainMoreMenuAction.LocateSelected -> onAction(MainAction.LocateSelectedServer)
|
||||
MainMoreMenuAction.SortByTestResults -> onAction(MainAction.SortByTestResults)
|
||||
MainMoreMenuAction.TestAll -> onAction(MainAction.TestAllServers)
|
||||
MainMoreMenuAction.TestAllRealPing -> onAction(MainAction.TestRealAllServers)
|
||||
MainMoreMenuAction.UpdateSubscriptions -> onAction(MainAction.UpdateSubscriptions)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
@@ -269,8 +224,8 @@ fun MainScreen(
|
||||
groupId = group.id,
|
||||
mainViewModel = mainViewModel,
|
||||
selectedGuid = selectedGuid,
|
||||
locateTarget = uiState.locateTarget,
|
||||
doubleColumnDisplay = doubleColumnDisplay,
|
||||
confirmRemove = confirmRemove,
|
||||
searchQuery = searchQuery,
|
||||
lazyListStates = lazyListStates,
|
||||
lazyGridStates = lazyGridStates,
|
||||
|
||||
@@ -30,12 +30,16 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.LineBreak
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -43,12 +47,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.LocateTarget
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.ReorderableGridItem
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
@@ -66,8 +66,8 @@ fun GroupPagerPage(
|
||||
groupId: String,
|
||||
mainViewModel: MainViewModel,
|
||||
selectedGuid: String?,
|
||||
locateTarget: LocateTarget?,
|
||||
doubleColumnDisplay: Boolean,
|
||||
confirmRemove: Boolean,
|
||||
searchQuery: String,
|
||||
lazyListStates: MutableMap<String, LazyListState>,
|
||||
lazyGridStates: MutableMap<String, LazyGridState>,
|
||||
@@ -78,47 +78,64 @@ fun GroupPagerPage(
|
||||
onRemoveServer: (String) -> Unit,
|
||||
contentPadding: PaddingValues
|
||||
) {
|
||||
val serverFlow = remember(groupId) {
|
||||
mainViewModel.serversForGroup(groupId)
|
||||
val groupStateFlow = remember(groupId) {
|
||||
mainViewModel.serverGroupState(groupId)
|
||||
}
|
||||
val servers by serverFlow.collectAsStateWithLifecycle()
|
||||
val groupState by groupStateFlow.collectAsStateWithLifecycle()
|
||||
val canReorder = groupId.isNotEmpty() && searchQuery.isEmpty()
|
||||
val actions = remember(
|
||||
onSelectServer,
|
||||
onEditServer,
|
||||
onShareServer,
|
||||
onMoreServer,
|
||||
onRemoveServer,
|
||||
) {
|
||||
ServerRowActions(
|
||||
select = onSelectServer,
|
||||
edit = onEditServer,
|
||||
share = onShareServer,
|
||||
more = onMoreServer,
|
||||
remove = onRemoveServer,
|
||||
)
|
||||
}
|
||||
ServerListPage(
|
||||
servers = servers,
|
||||
rows = groupState.rows,
|
||||
selectedGuid = selectedGuid,
|
||||
locateTarget = locateTarget?.takeIf { it.groupId == groupId },
|
||||
canReorder = canReorder,
|
||||
doubleColumnDisplay = doubleColumnDisplay,
|
||||
subscriptionId = groupId,
|
||||
confirmRemove = confirmRemove,
|
||||
groupId = groupId,
|
||||
lazyListStates = lazyListStates,
|
||||
lazyGridStates = lazyGridStates,
|
||||
onSelectServer = onSelectServer,
|
||||
onEditServer = onEditServer,
|
||||
onShareServer = onShareServer,
|
||||
onMoreServer = onMoreServer,
|
||||
onRemoveServer = onRemoveServer,
|
||||
onMoveServer = { fromIndex, toIndex -> mainViewModel.moveServer(groupId, fromIndex, toIndex) },
|
||||
actions = actions,
|
||||
onLocateHandled = { mainViewModel.onAction(MainAction.LocateHandled) },
|
||||
onMoveServer = { fromIndex, toIndex ->
|
||||
mainViewModel.moveServer(groupId, fromIndex, toIndex)
|
||||
},
|
||||
contentPadding = contentPadding
|
||||
)
|
||||
}
|
||||
|
||||
private class ServerRowActions(
|
||||
val select: (String) -> Unit,
|
||||
val edit: (String, ProfileItem) -> Unit,
|
||||
val share: (String, ProfileItem) -> Unit,
|
||||
val more: (String, ProfileItem) -> Unit,
|
||||
val remove: (String) -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ServerListPage(
|
||||
servers: List<ServersCache>,
|
||||
rows: List<ServerRowUiModel>,
|
||||
selectedGuid: String?,
|
||||
locateTarget: LocateTarget?,
|
||||
canReorder: Boolean,
|
||||
doubleColumnDisplay: Boolean,
|
||||
subscriptionId: String,
|
||||
confirmRemove: Boolean,
|
||||
groupId: String,
|
||||
lazyListStates: MutableMap<String, LazyListState>,
|
||||
lazyGridStates: MutableMap<String, LazyGridState>,
|
||||
onSelectServer: (String) -> Unit,
|
||||
onEditServer: (String, ProfileItem) -> Unit,
|
||||
onShareServer: (String, ProfileItem) -> Unit,
|
||||
onMoreServer: (String, ProfileItem) -> Unit,
|
||||
onRemoveServer: (String) -> Unit,
|
||||
actions: ServerRowActions,
|
||||
onLocateHandled: () -> Unit,
|
||||
onMoveServer: (Int, Int) -> Unit,
|
||||
contentPadding: PaddingValues
|
||||
) {
|
||||
@@ -132,6 +149,8 @@ private fun ServerListPage(
|
||||
}
|
||||
} else null
|
||||
|
||||
LocateTargetEffect(locateTarget, rows, gridState, onLocateHandled)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
state = gridState,
|
||||
@@ -140,24 +159,19 @@ private fun ServerListPage(
|
||||
.verticalScrollbar(gridState),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache ->
|
||||
itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row ->
|
||||
val content: @Composable () -> Unit = {
|
||||
ServerItemColumn(
|
||||
serverCache = serverCache,
|
||||
selectedGuid = selectedGuid,
|
||||
subscriptionId = subscriptionId,
|
||||
row = row,
|
||||
isSelected = row.guid == selectedGuid,
|
||||
doubleColumnDisplay = true,
|
||||
onSelectServer = onSelectServer,
|
||||
onEditServer = onEditServer,
|
||||
onShareServer = onShareServer,
|
||||
onMoreServer = onMoreServer,
|
||||
onRemoveServer = onRemoveServer
|
||||
actions = actions
|
||||
)
|
||||
}
|
||||
if (canReorder && reorderableGridState != null) {
|
||||
ReorderableItem(
|
||||
reorderableGridState,
|
||||
key = serverCache.guid
|
||||
key = row.guid
|
||||
) { isDragging ->
|
||||
ReorderableGridItem(
|
||||
scope = this,
|
||||
@@ -179,6 +193,8 @@ private fun ServerListPage(
|
||||
}
|
||||
} else null
|
||||
|
||||
LocateTargetEffect(locateTarget, rows, listState, onLocateHandled)
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
@@ -186,39 +202,29 @@ private fun ServerListPage(
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache ->
|
||||
itemsIndexed(items = rows, key = { _, item -> item.guid }) { _, row ->
|
||||
if (canReorder && reorderableState != null) {
|
||||
ReorderableItem(
|
||||
reorderableState,
|
||||
key = serverCache.guid
|
||||
key = row.guid
|
||||
) { isDragging ->
|
||||
ReorderableListItem(
|
||||
scope = this,
|
||||
isDragging = isDragging
|
||||
) {
|
||||
ServerItemRow(
|
||||
serverCache = serverCache,
|
||||
selectedGuid = selectedGuid,
|
||||
subscriptionId = subscriptionId,
|
||||
onSelectServer = onSelectServer,
|
||||
onEditServer = onEditServer,
|
||||
onShareServer = onShareServer,
|
||||
onMoreServer = onMoreServer,
|
||||
onRemoveServer = onRemoveServer
|
||||
row = row,
|
||||
isSelected = row.guid == selectedGuid,
|
||||
actions = actions
|
||||
)
|
||||
}
|
||||
ItemDivider()
|
||||
}
|
||||
} else {
|
||||
ServerItemRow(
|
||||
serverCache = serverCache,
|
||||
selectedGuid = selectedGuid,
|
||||
subscriptionId = subscriptionId,
|
||||
onSelectServer = onSelectServer,
|
||||
onEditServer = onEditServer,
|
||||
onShareServer = onShareServer,
|
||||
onMoreServer = onMoreServer,
|
||||
onRemoveServer = onRemoveServer
|
||||
row = row,
|
||||
isSelected = row.guid == selectedGuid,
|
||||
actions = actions
|
||||
)
|
||||
ItemDivider()
|
||||
}
|
||||
@@ -228,100 +234,96 @@ private fun ServerListPage(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerItemRow(
|
||||
serverCache: ServersCache,
|
||||
selectedGuid: String?,
|
||||
subscriptionId: String,
|
||||
onSelectServer: (String) -> Unit,
|
||||
onEditServer: (String, ProfileItem) -> Unit,
|
||||
onShareServer: (String, ProfileItem) -> Unit,
|
||||
onMoreServer: (String, ProfileItem) -> Unit,
|
||||
onRemoveServer: (String) -> Unit
|
||||
private fun LocateTargetEffect(
|
||||
target: LocateTarget?,
|
||||
rows: List<ServerRowUiModel>,
|
||||
state: LazyListState,
|
||||
onHandled: () -> Unit,
|
||||
) {
|
||||
val profile = serverCache.profile
|
||||
val subRemarks = if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
||||
?.toString() ?: ""
|
||||
} else ""
|
||||
if (target == null) return
|
||||
LaunchedEffect(target, rows) {
|
||||
val index = rows.indexOfFirst { it.guid == target.serverGuid }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
|
||||
onHandled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocateTargetEffect(
|
||||
target: LocateTarget?,
|
||||
rows: List<ServerRowUiModel>,
|
||||
state: LazyGridState,
|
||||
onHandled: () -> Unit,
|
||||
) {
|
||||
if (target == null) return
|
||||
LaunchedEffect(target, rows) {
|
||||
val index = rows.indexOfFirst { it.guid == target.serverGuid }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
state.scrollToItem(index, -state.layoutInfo.viewportSize.height / 3)
|
||||
onHandled()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerItemRow(
|
||||
row: ServerRowUiModel,
|
||||
isSelected: Boolean,
|
||||
actions: ServerRowActions
|
||||
) {
|
||||
ServerListItem(
|
||||
remarks = profile.remarks,
|
||||
statistics = profile.description.nullIfBlank()
|
||||
?: AngConfigManager.generateDescription(profile),
|
||||
typeDescription = getProtocolDescription(profile),
|
||||
testResult = serverCache.testDelayString,
|
||||
testDelayMillis = serverCache.testDelayMillis,
|
||||
isSelected = serverCache.guid == selectedGuid,
|
||||
subscriptionRemarks = subRemarks,
|
||||
row = row,
|
||||
isSelected = isSelected,
|
||||
doubleColumnDisplay = false,
|
||||
onClick = { onSelectServer(serverCache.guid) },
|
||||
onShare = { onShareServer(serverCache.guid, profile) },
|
||||
onEdit = { onEditServer(serverCache.guid, profile) },
|
||||
onRemove = { onRemoveServer(serverCache.guid) },
|
||||
onMore = { onMoreServer(serverCache.guid, profile) }
|
||||
actions = actions
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerItemColumn(
|
||||
serverCache: ServersCache,
|
||||
selectedGuid: String?,
|
||||
subscriptionId: String,
|
||||
row: ServerRowUiModel,
|
||||
isSelected: Boolean,
|
||||
doubleColumnDisplay: Boolean,
|
||||
onSelectServer: (String) -> Unit,
|
||||
onEditServer: (String, ProfileItem) -> Unit,
|
||||
onShareServer: (String, ProfileItem) -> Unit,
|
||||
onMoreServer: (String, ProfileItem) -> Unit,
|
||||
onRemoveServer: (String) -> Unit
|
||||
actions: ServerRowActions
|
||||
) {
|
||||
val profile = serverCache.profile
|
||||
val subRemarks = if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()?.toString() ?: ""
|
||||
} else ""
|
||||
Column {
|
||||
ServerListItem(
|
||||
remarks = profile.remarks,
|
||||
statistics = profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile),
|
||||
typeDescription = getProtocolDescription(profile),
|
||||
testResult = serverCache.testDelayString,
|
||||
testDelayMillis = serverCache.testDelayMillis,
|
||||
isSelected = serverCache.guid == selectedGuid,
|
||||
subscriptionRemarks = subRemarks,
|
||||
row = row,
|
||||
isSelected = isSelected,
|
||||
doubleColumnDisplay = doubleColumnDisplay,
|
||||
onClick = { onSelectServer(serverCache.guid) },
|
||||
onEdit = { onEditServer(serverCache.guid, profile) },
|
||||
onShare = { onShareServer(serverCache.guid, profile) },
|
||||
onRemove = { onRemoveServer(serverCache.guid) },
|
||||
onMore = { onMoreServer(serverCache.guid, profile) }
|
||||
actions = actions
|
||||
)
|
||||
ItemDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerListItem(
|
||||
remarks: String,
|
||||
statistics: String,
|
||||
typeDescription: String,
|
||||
testResult: String,
|
||||
testDelayMillis: Long,
|
||||
private fun ServerListItem(
|
||||
row: ServerRowUiModel,
|
||||
isSelected: Boolean,
|
||||
subscriptionRemarks: String,
|
||||
doubleColumnDisplay: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
onMore: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dragModifier: Modifier = Modifier
|
||||
actions: ServerRowActions
|
||||
) {
|
||||
val testResult = if (row.testDelayMillis == 0L) {
|
||||
""
|
||||
} else {
|
||||
stringResource(R.string.server_test_delay_value, row.testDelayMillis)
|
||||
}
|
||||
val selectedStateDescription = if (isSelected) {
|
||||
stringResource(R.string.acc_selected_server)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.clickable(onClick = onClick)
|
||||
.then(dragModifier)
|
||||
.semantics {
|
||||
if (selectedStateDescription != null) {
|
||||
stateDescription = selectedStateDescription
|
||||
}
|
||||
}
|
||||
.clickable { actions.select(row.guid) }
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -348,58 +350,69 @@ fun ServerListItem(
|
||||
.padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(row.remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (doubleColumnDisplay) {
|
||||
IconButton(onClick = onMore, Modifier.size(36.dp)) {
|
||||
Icon(painterResource(R.drawable.ic_more_vert_24dp), null, Modifier.size(24.dp))
|
||||
IconButton(onClick = { actions.more(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
stringResource(R.string.acc_more),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onShare, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_share_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = onEdit, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_edit_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = onRemove, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_delete_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = { actions.share(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_share_24dp),
|
||||
stringResource(R.string.title_configuration_share),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { actions.edit(row.guid, row.profile) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_edit_24dp),
|
||||
stringResource(R.string.acc_edit),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { actions.remove(row.guid) }, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_delete_24dp),
|
||||
stringResource(R.string.acc_delete),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (subscriptionRemarks.isNotBlank()) {
|
||||
if (row.subscriptionBadge.isNotBlank()) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), Alignment.Center
|
||||
) {
|
||||
Text(subscriptionRemarks.take(1).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
Text(row.subscriptionBadge.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Text(statistics, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
row.statistics,
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(row.typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (row.testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getProtocolDescription(profile: ProfileItem): String {
|
||||
if (profile.configType.isComplexType()) return profile.configType.name
|
||||
val parts = mutableListOf(profile.configType.name)
|
||||
profile.network?.let { net ->
|
||||
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) parts.add(net)
|
||||
}
|
||||
profile.security?.let { sec ->
|
||||
if (sec.isNotBlank()) {
|
||||
if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) {
|
||||
parts.add("$sec insecure")
|
||||
} else {
|
||||
parts.add(sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.joinToString(" / ")
|
||||
}
|
||||
|
||||
internal suspend fun PagerState.navigateToPageOptimized(
|
||||
targetPage: Int,
|
||||
animateAdjacentPage: Boolean = true
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
|
||||
internal data class ServerRowUiModel(
|
||||
val guid: String,
|
||||
val profile: ProfileItem,
|
||||
val remarks: String,
|
||||
val statistics: String,
|
||||
val typeDescription: String,
|
||||
val testDelayMillis: Long,
|
||||
val subscriptionBadge: String,
|
||||
)
|
||||
|
||||
internal data class ServerGroupUiState(
|
||||
val servers: List<ServersCache> = emptyList(),
|
||||
val rows: List<ServerRowUiModel> = emptyList(),
|
||||
)
|
||||
|
||||
internal fun buildServerRowUiModel(
|
||||
server: ServersCache,
|
||||
subscriptionRemarks: String,
|
||||
): ServerRowUiModel {
|
||||
val profile = server.profile
|
||||
return ServerRowUiModel(
|
||||
guid = server.guid,
|
||||
profile = profile,
|
||||
remarks = profile.remarks,
|
||||
statistics = profile.description.nullIfBlank()
|
||||
?: AngConfigManager.generateDescription(profile),
|
||||
typeDescription = serverProtocolDescription(profile),
|
||||
testDelayMillis = server.testDelayMillis,
|
||||
subscriptionBadge = subscriptionRemarks.firstOrNull()?.toString().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun serverProtocolDescription(profile: ProfileItem): String {
|
||||
if (profile.configType.isComplexType()) return profile.configType.name
|
||||
val parts = mutableListOf(profile.configType.name)
|
||||
profile.network?.let { network ->
|
||||
if (network.isNotBlank() && !network.equals("tcp", ignoreCase = true)) {
|
||||
parts.add(network)
|
||||
}
|
||||
}
|
||||
profile.security?.let { security ->
|
||||
if (security.isNotBlank()) {
|
||||
parts.add(
|
||||
if (profile.insecure == true && security.equals("tls", ignoreCase = true)) {
|
||||
"$security insecure"
|
||||
} else {
|
||||
security
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return parts.joinToString(" / ")
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import com.v2ray.ang.dto.ConnectionTestResult
|
||||
|
||||
sealed class MainServiceEvent {
|
||||
data object StateRunning : MainServiceEvent()
|
||||
data object StateNotRunning : MainServiceEvent()
|
||||
data object StateStartSuccess : MainServiceEvent()
|
||||
data class StateStartFailure(val errorMessage: String) : MainServiceEvent()
|
||||
data object StateStartFailure : MainServiceEvent()
|
||||
data object StateStopSuccess : MainServiceEvent()
|
||||
data class MeasureDelaySuccess(val content: String) : MainServiceEvent()
|
||||
data class MeasureDelayResult(val result: ConnectionTestResult) : MainServiceEvent()
|
||||
data object MeasureConfigSuccess : MainServiceEvent()
|
||||
data class MeasureConfigNotify(val progress: String) : MainServiceEvent()
|
||||
data class MeasureConfigFinish(val finishedCount: String?) : MainServiceEvent()
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -28,7 +27,6 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainTopBar(
|
||||
isLoading: Boolean,
|
||||
@@ -39,9 +37,7 @@ fun MainTopBar(
|
||||
onSearchToggle: (Boolean) -> Unit,
|
||||
onMenuClick: () -> Unit,
|
||||
onAction: (MainAction) -> Unit,
|
||||
onDelAllConfig: () -> Unit,
|
||||
onDelDuplicateConfig: () -> Unit,
|
||||
onDelInvalidConfig: () -> Unit
|
||||
onMoreMenuAction: (MainMoreMenuAction) -> Unit
|
||||
) {
|
||||
var showImportMenu by remember { mutableStateOf(false) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
@@ -63,23 +59,23 @@ fun MainTopBar(
|
||||
navigationIcon = {
|
||||
if (showSearch) {
|
||||
IconButton(onClick = onSearchClose) {
|
||||
Icon(painterResource(R.drawable.ic_arrow_back_24dp), contentDescription = "Back")
|
||||
Icon(painterResource(R.drawable.ic_arrow_back_24dp), contentDescription = stringResource(R.string.acc_back))
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onMenuClick) {
|
||||
Icon(painterResource(R.drawable.ic_menu_24dp), contentDescription = "Menu")
|
||||
Icon(painterResource(R.drawable.ic_menu_24dp), contentDescription = stringResource(R.string.acc_open_menu))
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!showSearch) {
|
||||
IconButton(onClick = { onSearchToggle(true) }) {
|
||||
Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = "filter")
|
||||
Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = stringResource(R.string.acc_search))
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
|
||||
IconButton(onClick = { showImportMenu = true }) {
|
||||
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = "Add")
|
||||
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = stringResource(R.string.acc_add))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showImportMenu,
|
||||
@@ -100,7 +96,7 @@ fun MainTopBar(
|
||||
}
|
||||
Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(painterResource(R.drawable.ic_more_vert_24dp), contentDescription = "More")
|
||||
Icon(painterResource(R.drawable.ic_more_vert_24dp), contentDescription = stringResource(R.string.acc_more))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
@@ -111,15 +107,10 @@ fun MainTopBar(
|
||||
.heightIn(max = maxMenuHeight)
|
||||
.verticalScrollbar(moreMenuScrollState)
|
||||
) {
|
||||
MoreMenuContent(
|
||||
onAction = { action ->
|
||||
showMenu = false
|
||||
onAction(action)
|
||||
},
|
||||
onDelAllConfig = { showMenu = false; onDelAllConfig() },
|
||||
onDelDuplicateConfig = { showMenu = false; onDelDuplicateConfig() },
|
||||
onDelInvalidConfig = { showMenu = false; onDelInvalidConfig() }
|
||||
)
|
||||
MoreMenuContent { action ->
|
||||
showMenu = false
|
||||
onMoreMenuAction(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.ConnectionTestResult
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.LocateTarget
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.matchesPattern
|
||||
import com.v2ray.ang.extension.moveItem
|
||||
@@ -23,11 +25,13 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -45,15 +49,11 @@ class MainViewModel(
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
private val preloadDispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||
|
||||
private val disconnectedText: String = dataSource.getString(R.string.connection_not_connected)
|
||||
private val connectedText: String = dataSource.getString(R.string.connection_connected)
|
||||
|
||||
// ---------- UI state ----------
|
||||
private val _uiState = MutableStateFlow(
|
||||
MainUiState(
|
||||
selectedGroupId = dataSource.getSelectedSubscriptionId(),
|
||||
selectedGuid = dataSource.getSelectServer(),
|
||||
statusText = disconnectedText,
|
||||
confirmRemove = dataSource.getConfirmRemove(),
|
||||
doubleColumnDisplay = dataSource.getDoubleColumnDisplay()
|
||||
)
|
||||
@@ -68,7 +68,8 @@ class MainViewModel(
|
||||
// ---------- Groups & cache ----------
|
||||
private val cacheMutex = Mutex()
|
||||
private val groupDataCache = mutableMapOf<String, List<ServersCache>>()
|
||||
private val groupPageFlows = ConcurrentHashMap<String, MutableStateFlow<List<ServersCache>>>()
|
||||
private val groupUiFlows = ConcurrentHashMap<String, MutableStateFlow<ServerGroupUiState>>()
|
||||
private val groupServerFlows = ConcurrentHashMap<String, StateFlow<List<ServersCache>>>()
|
||||
private val groupLoadMutexes = ConcurrentHashMap<String, Mutex>()
|
||||
private val serverOrderPersistenceJobs = mutableMapOf<String, Job>()
|
||||
|
||||
@@ -105,19 +106,14 @@ class MainViewModel(
|
||||
updateRunningState(true)
|
||||
}
|
||||
|
||||
is MainServiceEvent.StateStartFailure -> {
|
||||
val error = event.errorMessage
|
||||
if (error.isNotBlank()) {
|
||||
toastError(error)
|
||||
} else {
|
||||
toastError(R.string.toast_services_failure)
|
||||
}
|
||||
MainServiceEvent.StateStartFailure -> {
|
||||
toastError(R.string.toast_services_failure)
|
||||
updateRunningState(false)
|
||||
}
|
||||
|
||||
MainServiceEvent.StateStopSuccess -> updateRunningState(false)
|
||||
is MainServiceEvent.MeasureDelaySuccess -> {
|
||||
_uiState.update { it.copy(statusText = event.content) }
|
||||
is MainServiceEvent.MeasureDelayResult -> {
|
||||
_uiState.update { it.copy(status = MainStatus.ConnectionTest(event.result)) }
|
||||
}
|
||||
|
||||
MainServiceEvent.MeasureConfigSuccess -> {
|
||||
@@ -129,34 +125,67 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
is MainServiceEvent.MeasureConfigNotify -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
statusText = dataSource.getString(
|
||||
R.string.connection_runing_task_left,
|
||||
event.progress
|
||||
)
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
|
||||
}
|
||||
|
||||
is MainServiceEvent.MeasureConfigFinish -> {
|
||||
if (event.finishedCount == "0") {
|
||||
onTestsFinished()
|
||||
}
|
||||
onTestsFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatStatus(status: MainStatus): String = when (status) {
|
||||
MainStatus.Disconnected -> dataSource.getString(R.string.connection_not_connected)
|
||||
MainStatus.Connected -> dataSource.getString(R.string.connection_connected)
|
||||
MainStatus.Testing -> dataSource.getString(R.string.connection_test_testing)
|
||||
is MainStatus.TestProgress -> dataSource.getString(
|
||||
R.string.connection_running_task_left,
|
||||
status.progress
|
||||
)
|
||||
|
||||
is MainStatus.ConnectionTest -> formatConnectionTestResult(status.result)
|
||||
}
|
||||
|
||||
private fun formatConnectionTestResult(result: ConnectionTestResult): String {
|
||||
val status = if (result.delayMillis >= 0) {
|
||||
val delay = dataSource.getString(R.string.server_test_delay_value, result.delayMillis)
|
||||
dataSource.getString(R.string.connection_test_available, delay)
|
||||
} else {
|
||||
val detail = result.errorMessage.ifBlank {
|
||||
dataSource.getString(R.string.connection_test_empty_message)
|
||||
}
|
||||
dataSource.getString(R.string.connection_test_error, detail)
|
||||
}
|
||||
|
||||
if (result.delayMillis < 0 || (result.country == null && result.ipAddress == null)) {
|
||||
return status
|
||||
}
|
||||
|
||||
val unknown = dataSource.getString(R.string.value_unknown)
|
||||
return "$status\n(${result.country ?: unknown}) ${result.ipAddress ?: unknown}"
|
||||
}
|
||||
|
||||
// ---------- Public state accessors ----------
|
||||
fun serversForGroup(groupId: String): StateFlow<List<ServersCache>> =
|
||||
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) }
|
||||
.asStateFlow()
|
||||
groupServerFlows.computeIfAbsent(groupId) {
|
||||
val groupState = mutableServerGroupState(groupId)
|
||||
groupState
|
||||
.map { it.servers }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
|
||||
initialValue = groupState.value.servers,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mutableServersForGroup(groupId: String): MutableStateFlow<List<ServersCache>> =
|
||||
groupPageFlows.computeIfAbsent(groupId) { MutableStateFlow(emptyList()) }
|
||||
internal fun serverGroupState(groupId: String): StateFlow<ServerGroupUiState> =
|
||||
mutableServerGroupState(groupId).asStateFlow()
|
||||
|
||||
private fun mutableServerGroupState(groupId: String): MutableStateFlow<ServerGroupUiState> =
|
||||
groupUiFlows.computeIfAbsent(groupId) { MutableStateFlow(ServerGroupUiState()) }
|
||||
|
||||
private fun currentServers(): List<ServersCache> =
|
||||
mutableServersForGroup(uiState.value.selectedGroupId).value
|
||||
mutableServerGroupState(uiState.value.selectedGroupId).value.servers
|
||||
|
||||
// ---------- Action handler ----------
|
||||
fun onAction(action: MainAction) {
|
||||
@@ -177,7 +206,7 @@ class MainViewModel(
|
||||
is MainAction.RemoveServer -> removeServerAndRefresh(action.guid)
|
||||
is MainAction.Search -> filterConfig(action.query)
|
||||
is MainAction.ImportBatchConfig -> importBatchConfig(action.configText)
|
||||
is MainAction.LocateHandled -> consumeLocateTarget(action.target)
|
||||
MainAction.LocateHandled -> consumeLocateTarget()
|
||||
is MainAction.ShareQRCode -> {
|
||||
val bitmap = dataSource.share2QRCode(action.guid)
|
||||
_uiState.update { it.copy(shareQRCodeBitmap = bitmap) }
|
||||
@@ -208,7 +237,7 @@ class MainViewModel(
|
||||
viewModelScope.launch(preloadDispatcher) {
|
||||
try {
|
||||
initialPageReady.await()
|
||||
delay(32L)
|
||||
delay(32)
|
||||
dataSource.initAssets()
|
||||
dataSource.syncSubscriptions()
|
||||
} catch (cancelled: CancellationException) {
|
||||
@@ -237,8 +266,7 @@ class MainViewModel(
|
||||
ServersCache(
|
||||
guid = guid,
|
||||
profile = profile.copy(),
|
||||
testDelayMillis = affiliation?.testDelayMillis ?: 0L,
|
||||
testDelayString = affiliation?.getTestDelayString().orEmpty()
|
||||
testDelayMillis = affiliation?.testDelayMillis ?: 0L
|
||||
)
|
||||
}
|
||||
|
||||
@@ -276,7 +304,31 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
private fun updateGroupUi(groupId: String, servers: List<ServersCache>) {
|
||||
mutableServersForGroup(groupId).value = applyKeywordFilter(servers)
|
||||
val filteredServers = applyKeywordFilter(servers)
|
||||
mutableServerGroupState(groupId).value = ServerGroupUiState(
|
||||
servers = filteredServers,
|
||||
rows = buildServerRows(groupId, filteredServers)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildServerRows(groupId: String, servers: List<ServersCache>): List<ServerRowUiModel> {
|
||||
val subscriptionRemarks = if (groupId.isEmpty()) {
|
||||
servers.asSequence()
|
||||
.map { it.profile.subscriptionId }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.associateWith { subscriptionId ->
|
||||
dataSource.getSubscriptionItem(subscriptionId)?.remarks.orEmpty()
|
||||
}
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
return servers.map { server ->
|
||||
buildServerRowUiModel(
|
||||
server = server,
|
||||
subscriptionRemarks = subscriptionRemarks[server.profile.subscriptionId].orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubscriptions(): List<SubscriptionCache> = dataSource.getSubscriptions()
|
||||
@@ -321,17 +373,18 @@ class MainViewModel(
|
||||
}
|
||||
val selectedGroup = resolveSelectedGroup(groups)
|
||||
val validIds = groups.mapTo(HashSet()) { it.id }
|
||||
groupPageFlows.keys.removeAll { it !in validIds }
|
||||
groupUiFlows.keys.removeAll { it !in validIds }
|
||||
groupServerFlows.keys.removeAll { it !in validIds }
|
||||
groupLoadMutexes.keys.removeAll { it !in validIds }
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
groups = groups,
|
||||
selectedGroupId = selectedGroup,
|
||||
selectedGuid = dataSource.getSelectServer()
|
||||
selectedGuid = dataSource.getSelectServer(),
|
||||
)
|
||||
}
|
||||
groups.forEach { mutableServersForGroup(it.id) }
|
||||
groups.forEach { mutableServerGroupState(it.id) }
|
||||
|
||||
if (groups.isEmpty()) {
|
||||
cacheMutex.withLock { groupDataCache.clear() }
|
||||
@@ -351,7 +404,7 @@ class MainViewModel(
|
||||
preloadJob = viewModelScope.launch(preloadDispatcher) {
|
||||
preloadOrder.forEach { groupId ->
|
||||
ensureActive()
|
||||
delay(32L)
|
||||
delay(32)
|
||||
val servers = loadGroup(groupId, forceRefresh)
|
||||
updateGroupUi(groupId, servers)
|
||||
}
|
||||
@@ -411,7 +464,13 @@ class MainViewModel(
|
||||
toast(R.string.title_update_subscription_no_subscription)
|
||||
|
||||
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
|
||||
toast(dataSource.getString(R.string.title_update_config_count, result.configCount))
|
||||
toast(
|
||||
getQuantityString(
|
||||
R.plurals.title_update_config_count,
|
||||
result.configCount,
|
||||
result.configCount,
|
||||
)
|
||||
)
|
||||
|
||||
else ->
|
||||
toast(dataSource.getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
|
||||
@@ -569,7 +628,7 @@ class MainViewModel(
|
||||
|
||||
fun subscriptionIdChanged(id: String) {
|
||||
if (_uiState.value.groups.none { it.id == id }) return
|
||||
mutableServersForGroup(id)
|
||||
mutableServerGroupState(id)
|
||||
if (uiState.value.selectedGroupId != id) {
|
||||
dataSource.setSelectedSubscriptionId(id)
|
||||
_uiState.update { it.copy(selectedGroupId = id) }
|
||||
@@ -604,7 +663,7 @@ class MainViewModel(
|
||||
}
|
||||
order.forEachIndexed { index, groupId ->
|
||||
ensureActive()
|
||||
if (index > 0) delay(32L)
|
||||
if (index > 0) delay(32)
|
||||
updateGroupUi(groupId, loadGroup(groupId, forceRefresh = true))
|
||||
}
|
||||
}
|
||||
@@ -615,7 +674,7 @@ class MainViewModel(
|
||||
keywordFilter = keyword
|
||||
filterJob?.cancel()
|
||||
filterJob = viewModelScope.launch(defaultDispatcher) {
|
||||
delay(300L)
|
||||
delay(300)
|
||||
val snapshot = cacheMutex.withLock { groupDataCache.toMap() }
|
||||
ensureActive()
|
||||
snapshot.forEach { (groupId, servers) ->
|
||||
@@ -647,10 +706,13 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
fun moveServer(groupId: String, fromPosition: Int, toPosition: Int) {
|
||||
val servers = mutableServersForGroup(groupId).value.toMutableList()
|
||||
val groupState = mutableServerGroupState(groupId).value
|
||||
val servers = groupState.servers.toMutableList()
|
||||
if (!servers.moveItem(fromPosition, toPosition)) return
|
||||
val rows = groupState.rows.toMutableList()
|
||||
rows.moveItem(fromPosition, toPosition)
|
||||
val guids = servers.map { it.guid }
|
||||
mutableServersForGroup(groupId).value = servers
|
||||
mutableServerGroupState(groupId).value = ServerGroupUiState(servers, rows)
|
||||
// A drag emits several moves; serialize writes so an older order cannot overwrite a newer one.
|
||||
val previousPersistenceJob = serverOrderPersistenceJobs[groupId]
|
||||
serverOrderPersistenceJobs[groupId] = viewModelScope.launch(ioDispatcher) {
|
||||
@@ -667,7 +729,7 @@ class MainViewModel(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTesting = false,
|
||||
statusText = if (it.isRunning) connectedText else disconnectedText
|
||||
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -676,25 +738,38 @@ class MainViewModel(
|
||||
dataSource.cancelAllPing()
|
||||
val groupId = uiState.value.selectedGroupId
|
||||
val servers = currentServers()
|
||||
dataSource.clearAllTestDelayResults(servers.map { it.guid })
|
||||
if (servers.isEmpty()) {
|
||||
_uiState.update { it.copy(isTesting = false) }
|
||||
return
|
||||
}
|
||||
val serverGuids = servers.map { it.guid }
|
||||
mutableServerGroupState(groupId).update { current ->
|
||||
current.copy(
|
||||
servers = current.servers.map { server ->
|
||||
if (server.testDelayMillis == 0L) server
|
||||
else server.copy(testDelayMillis = 0L)
|
||||
},
|
||||
rows = current.rows.map { row ->
|
||||
if (row.testDelayMillis == 0L) row
|
||||
else row.copy(testDelayMillis = 0L)
|
||||
}
|
||||
)
|
||||
}
|
||||
testingGroupId = groupId
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTesting = true,
|
||||
statusText = dataSource.getString(R.string.connection_test_testing)
|
||||
status = MainStatus.Testing
|
||||
)
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
dataSource.clearAllTestDelayResults(serverGuids)
|
||||
cacheMutex.withLock { groupDataCache.remove(groupId) }
|
||||
dataSource.sendMsg2TestService(
|
||||
TestServiceMessage(
|
||||
key = AppConfig.MSG_MEASURE_CONFIG_START,
|
||||
subscriptionId = groupId,
|
||||
serverGuids = if (keywordFilter.isNotEmpty()) servers.map { it.guid } else emptyList(),
|
||||
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
|
||||
onlyTcp = onlyTcp
|
||||
)
|
||||
)
|
||||
@@ -702,11 +777,7 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
fun testCurrentServerRealPing() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
statusText = dataSource.getString(R.string.connection_test_testing)
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(status = MainStatus.Testing) }
|
||||
dataSource.testCurrentServerRealPing()
|
||||
}
|
||||
|
||||
@@ -717,7 +788,7 @@ class MainViewModel(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTesting = false,
|
||||
statusText = if (it.isRunning) connectedText else disconnectedText
|
||||
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
|
||||
)
|
||||
}
|
||||
reloadAllGroups(_uiState.value.groups.map { it.id })
|
||||
@@ -728,24 +799,21 @@ class MainViewModel(
|
||||
val selected = dataSource.getSelectServer() ?: return
|
||||
val profile = dataSource.decodeServerConfig(selected) ?: return
|
||||
val groupId = profile.subscriptionId
|
||||
val groupIndex =
|
||||
_uiState.value.groups.indexOfFirst { it.id == groupId }.takeIf { it >= 0 } ?: return
|
||||
if (_uiState.value.groups.none { it.id == groupId }) return
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val position =
|
||||
loadGroup(groupId).indexOfFirst { it.guid == selected }.takeIf { it >= 0 }
|
||||
?: return@launch
|
||||
updateGroupUi(groupId, loadGroup(groupId))
|
||||
if (_uiState.value.selectedGroupId != groupId) {
|
||||
dataSource.setSelectedSubscriptionId(groupId)
|
||||
}
|
||||
val target = LocateTarget(groupId, selected)
|
||||
_uiState.update {
|
||||
it.copy(locateTarget = LocateTarget(groupId, groupIndex, position))
|
||||
it.copy(selectedGroupId = groupId, locateTarget = target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPosition(guid: String): Int = currentServers().indexOfFirst { it.guid == guid }
|
||||
|
||||
private fun consumeLocateTarget(target: LocateTarget) {
|
||||
_uiState.update { state ->
|
||||
if (state.locateTarget == target) state.copy(locateTarget = null) else state
|
||||
}
|
||||
private fun consumeLocateTarget() {
|
||||
_uiState.update { it.copy(locateTarget = null) }
|
||||
}
|
||||
|
||||
// ---------- Running state ----------
|
||||
@@ -753,8 +821,8 @@ class MainViewModel(
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isRunning = running,
|
||||
statusText = if (!clearTestingText && state.isTesting) state.statusText
|
||||
else if (running) connectedText else disconnectedText
|
||||
status = if (!clearTestingText && state.isTesting) state.status
|
||||
else if (running) MainStatus.Connected else MainStatus.Disconnected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package com.v2ray.ang.ui.perappproxy
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -15,13 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
@@ -42,17 +41,26 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.extension.toastInfo
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppDivider
|
||||
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppListItem
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
private enum class PerAppMenuAction(@StringRes val labelRes: Int) {
|
||||
SelectAll(R.string.menu_item_select_all),
|
||||
InvertSelection(R.string.menu_item_invert_selection),
|
||||
SelectProxyApps(R.string.menu_item_select_proxy_app),
|
||||
ImportSelection(R.string.menu_item_import_proxy_app),
|
||||
ExportSelection(R.string.menu_item_export_proxy_app)
|
||||
}
|
||||
|
||||
class PerAppProxyActivity : BaseComponentActivity() {
|
||||
|
||||
private val viewModel: PerAppProxyViewModel by viewModels()
|
||||
@@ -79,9 +87,6 @@ class PerAppProxyActivity : BaseComponentActivity() {
|
||||
onBackClick = { finish() },
|
||||
onPerAppProxyChanged = { viewModel.setPerAppProxyEnabled(it) },
|
||||
onBypassAppsChanged = { viewModel.setBypassAppsEnabled(it) },
|
||||
onInfoClick = {
|
||||
toastInfo(R.string.summary_pref_per_app_proxy)
|
||||
},
|
||||
onToggleApp = { viewModel.toggle(it) },
|
||||
onSearch = { viewModel.filterApps(it) },
|
||||
onSelectAll = { viewModel.selectAll() },
|
||||
@@ -100,7 +105,6 @@ class PerAppProxyActivity : BaseComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PerAppProxyScreen(
|
||||
apps: List<AppInfo>,
|
||||
@@ -111,7 +115,6 @@ fun PerAppProxyScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onPerAppProxyChanged: (Boolean) -> Unit,
|
||||
onBypassAppsChanged: (Boolean) -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
onToggleApp: (String) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
@@ -123,14 +126,16 @@ fun PerAppProxyScreen(
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showInfoDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val onInfoClick = { showInfoDialog = true }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
LaunchedEffect(Unit) {
|
||||
onSearch(searchQuery)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.per_app_proxy_settings),
|
||||
@@ -140,9 +145,11 @@ fun PerAppProxyScreen(
|
||||
searchQuery = searchQuery,
|
||||
onSearchQueryChange = { query ->
|
||||
searchQuery = query
|
||||
onSearch(query)
|
||||
},
|
||||
onSearchClose = {
|
||||
searchQuery = ""
|
||||
onSearch("")
|
||||
showSearch = false
|
||||
},
|
||||
searchPlaceholder = stringResource(R.string.menu_item_search),
|
||||
@@ -151,7 +158,7 @@ fun PerAppProxyScreen(
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_search_24dp),
|
||||
contentDescription = stringResource(R.string.menu_item_search)
|
||||
contentDescription = stringResource(R.string.acc_search)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -159,7 +166,7 @@ fun PerAppProxyScreen(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
contentDescription = stringResource(R.string.acc_more)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -167,26 +174,16 @@ fun PerAppProxyScreen(
|
||||
onDismissRequest = { showMenu = false },
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_select_all)) },
|
||||
onClick = { showMenu = false; onSelectAll() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_invert_selection)) },
|
||||
onClick = { showMenu = false; onInvertSelection() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_select_proxy_app)) },
|
||||
onClick = { showMenu = false; onSelectProxyAuto() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_import_proxy_app)) },
|
||||
onClick = { showMenu = false; onImportProxyApp() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_export_proxy_app)) },
|
||||
onClick = { showMenu = false; onExportProxyApp() }
|
||||
)
|
||||
AppDropdownMenuItems(PerAppMenuAction.entries, { it.labelRes }) { action ->
|
||||
showMenu = false
|
||||
when (action) {
|
||||
PerAppMenuAction.SelectAll -> onSelectAll()
|
||||
PerAppMenuAction.InvertSelection -> onInvertSelection()
|
||||
PerAppMenuAction.SelectProxyApps -> onSelectProxyAuto()
|
||||
PerAppMenuAction.ImportSelection -> onImportProxyApp()
|
||||
PerAppMenuAction.ExportSelection -> onExportProxyApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,7 +222,7 @@ fun PerAppProxyScreen(
|
||||
onCheckedChange = onPerAppProxyChanged,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
|
||||
checkedTrackColor = colorFabActive
|
||||
checkedTrackColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -246,14 +243,14 @@ fun PerAppProxyScreen(
|
||||
onCheckedChange = onBypassAppsChanged,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
|
||||
checkedTrackColor = colorFabActive
|
||||
checkedTrackColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onInfoClick) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_about_24dp),
|
||||
contentDescription = stringResource(R.string.summary_pref_per_app_proxy),
|
||||
contentDescription = stringResource(R.string.acc_per_app_proxy_information),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
@@ -265,14 +262,15 @@ fun PerAppProxyScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = blacklist.contains(app.packageName)
|
||||
AppListItem(
|
||||
appName = app.appName,
|
||||
packageName = app.packageName,
|
||||
icon = app.appIcon,
|
||||
icon = null,
|
||||
checked = checked,
|
||||
onCheckedChange = { onToggleApp(app.packageName) }
|
||||
)
|
||||
@@ -281,4 +279,13 @@ fun PerAppProxyScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showInfoDialog) {
|
||||
ConfirmDialog(
|
||||
message = stringResource(R.string.summary_pref_per_app_proxy),
|
||||
dismissText = null,
|
||||
onConfirm = {},
|
||||
onDismiss = { showInfoDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.AppSelection
|
||||
import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -46,64 +48,30 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
|
||||
|
||||
// Cached full list for filtering
|
||||
private var appsAll: List<AppInfo>? = null
|
||||
private var currentQuery = ""
|
||||
private var isAppListLoading = false
|
||||
|
||||
// Blacklist operations
|
||||
fun contains(packageName: String): Boolean = _blacklist.value.contains(packageName)
|
||||
|
||||
fun add(packageName: String) {
|
||||
val newSet = _blacklist.value + packageName
|
||||
if (newSet != _blacklist.value) {
|
||||
_blacklist.value = newSet
|
||||
saveBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(packageName: String) {
|
||||
val newSet = _blacklist.value - packageName
|
||||
if (newSet != _blacklist.value) {
|
||||
_blacklist.value = newSet
|
||||
saveBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle(packageName: String) {
|
||||
if (_blacklist.value.contains(packageName)) {
|
||||
remove(packageName)
|
||||
val currentSelection = _blacklist.value
|
||||
val newSelection = if (packageName in currentSelection) {
|
||||
currentSelection - packageName
|
||||
} else {
|
||||
add(packageName)
|
||||
}
|
||||
}
|
||||
|
||||
fun addAll(packages: Collection<String>) {
|
||||
val newSet = _blacklist.value + packages
|
||||
if (newSet != _blacklist.value) {
|
||||
_blacklist.value = newSet
|
||||
saveBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAll(packages: Collection<String>) {
|
||||
val newSet = _blacklist.value - packages.toSet()
|
||||
if (newSet != _blacklist.value) {
|
||||
_blacklist.value = newSet
|
||||
saveBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
if (_blacklist.value.isNotEmpty()) {
|
||||
_blacklist.value = emptySet()
|
||||
saveBlacklist()
|
||||
currentSelection + packageName
|
||||
}
|
||||
replaceBlacklist(newSelection)
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
|
||||
private fun loadBlacklist(): Set<String> {
|
||||
return MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
private fun saveBlacklist() {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, _blacklist.value.toMutableSet())
|
||||
SettingsChangeManager.makeRestartService()
|
||||
private fun replaceBlacklist(newBlacklist: Set<String>) {
|
||||
if (newBlacklist == _blacklist.value) return
|
||||
|
||||
_blacklist.value = newBlacklist
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, newBlacklist.toMutableSet())
|
||||
}
|
||||
|
||||
// Per‑app proxy switch
|
||||
@@ -123,36 +91,49 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
|
||||
|
||||
// Load and filter apps
|
||||
fun loadApps(context: Context) {
|
||||
if (appsAll != null || isAppListLoading) return
|
||||
|
||||
val applicationContext = context.applicationContext
|
||||
isAppListLoading = true
|
||||
launchLoading {
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val list = AppManagerUtil.loadNetworkAppList(context)
|
||||
val list = AppManagerUtil.loadNetworkAppList(applicationContext)
|
||||
sortApps(list)
|
||||
}
|
||||
appsAll = apps
|
||||
_displayedApps.value = apps
|
||||
_displayedApps.value = applyFilter(currentQuery)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.ANG_PACKAGE, "Error loading apps", e)
|
||||
} finally {
|
||||
isAppListLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun filterApps(query: String) {
|
||||
val key = query.uppercase()
|
||||
_displayedApps.value = if (key.isNotEmpty()) {
|
||||
appsAll?.filter {
|
||||
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
appsAll ?: emptyList()
|
||||
currentQuery = query
|
||||
_displayedApps.value = applyFilter(query)
|
||||
}
|
||||
|
||||
private fun applyFilter(query: String): List<AppInfo> {
|
||||
val apps = appsAll ?: return emptyList()
|
||||
if (query.isEmpty()) return apps
|
||||
|
||||
return apps.filter {
|
||||
it.appName.contains(query, ignoreCase = true) ||
|
||||
it.packageName.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
|
||||
val collator = Collator.getInstance()
|
||||
val selectedPackages = _blacklist.value
|
||||
return apps.sortedWith { p1, p2 ->
|
||||
val s1 = contains(p1.packageName)
|
||||
val s2 = contains(p2.packageName)
|
||||
val s1 = p1.packageName in selectedPackages
|
||||
val s2 = p2.packageName in selectedPackages
|
||||
when {
|
||||
s1 && !s2 -> -1
|
||||
!s1 && s2 -> 1
|
||||
@@ -165,19 +146,26 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
|
||||
|
||||
// Bulk actions
|
||||
fun selectAll() {
|
||||
val pkgNames = _displayedApps.value.map { it.packageName }
|
||||
val allSelected = pkgNames.all { contains(it) }
|
||||
if (allSelected) removeAll(pkgNames) else addAll(pkgNames)
|
||||
// Enable per‑app proxy after selection
|
||||
val displayedApps = _displayedApps.value
|
||||
val currentSelection = _blacklist.value
|
||||
val allSelected = displayedApps.all { it.packageName in currentSelection }
|
||||
val newSelection = currentSelection.toMutableSet().apply {
|
||||
displayedApps.forEach { app ->
|
||||
if (allSelected) remove(app.packageName) else add(app.packageName)
|
||||
}
|
||||
}
|
||||
replaceBlacklist(newSelection)
|
||||
enablePerAppProxyAndRestart()
|
||||
}
|
||||
|
||||
fun invertSelection() {
|
||||
_displayedApps.value.forEach { toggle(it.packageName) }
|
||||
val packageNames = _displayedApps.value.map { it.packageName }
|
||||
replaceBlacklist(AppSelection.invert(_blacklist.value, packageNames))
|
||||
enablePerAppProxyAndRestart()
|
||||
}
|
||||
|
||||
fun selectProxyAppAuto(context: Context) {
|
||||
val applicationContext = context.applicationContext
|
||||
launchLoading {
|
||||
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
|
||||
var content = withContext(Dispatchers.IO) {
|
||||
@@ -204,7 +192,11 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
|
||||
)
|
||||
} ?: ""
|
||||
}
|
||||
val success = selectProxyApp(content, true, context)
|
||||
val success = applyProxyAppList(
|
||||
content = content,
|
||||
context = applicationContext,
|
||||
forceGoogleApps = true
|
||||
)
|
||||
if (success) {
|
||||
enablePerAppProxyAndRestart()
|
||||
}
|
||||
@@ -213,42 +205,53 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
|
||||
|
||||
fun importProxyApp(content: String?, context: Context) {
|
||||
if (content.isNullOrEmpty()) return
|
||||
val success = selectProxyApp(content, false, context)
|
||||
if (success) {
|
||||
enablePerAppProxyAndRestart()
|
||||
|
||||
val applicationContext = context.applicationContext
|
||||
launchLoading {
|
||||
val success = applyProxyAppList(
|
||||
content = content,
|
||||
context = applicationContext,
|
||||
forceGoogleApps = false
|
||||
)
|
||||
if (success) {
|
||||
enablePerAppProxyAndRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportProxyApp(): String {
|
||||
var result = _bypassApps.value.toString()
|
||||
_blacklist.value.forEach { pkg ->
|
||||
result = result + System.lineSeparator() + pkg
|
||||
return buildString {
|
||||
append(_bypassApps.value)
|
||||
_blacklist.value.forEach { packageName ->
|
||||
append(System.lineSeparator())
|
||||
append(packageName)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun selectProxyApp(content: String, force: Boolean, context: Context): Boolean {
|
||||
try {
|
||||
val proxyApps = if (content.isEmpty()) {
|
||||
Utils.readTextFromAssets(context, "proxy_package_name")
|
||||
} else content
|
||||
if (proxyApps.isNullOrEmpty()) return false
|
||||
private suspend fun applyProxyAppList(content: String, context: Context, forceGoogleApps: Boolean): Boolean {
|
||||
val installedApps = appsAll ?: return false
|
||||
|
||||
clear()
|
||||
val apps = _displayedApps.value
|
||||
if (_bypassApps.value) {
|
||||
apps.forEach { app ->
|
||||
if (!inProxyApps(proxyApps, app.packageName, force)) {
|
||||
add(app.packageName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
apps.forEach { app ->
|
||||
if (inProxyApps(proxyApps, app.packageName, force)) {
|
||||
add(app.packageName)
|
||||
}
|
||||
try {
|
||||
val proxyAppList = if (content.isEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Utils.readTextFromAssets(context, "proxy_package_name")
|
||||
}
|
||||
} else content
|
||||
if (proxyAppList.isNullOrEmpty()) return false
|
||||
|
||||
val bypassApps = _bypassApps.value
|
||||
val newBlacklist = withContext(Dispatchers.Default) {
|
||||
AppSelection.fromProxyList(
|
||||
packageNames = installedApps.map { it.packageName },
|
||||
proxyAppList = proxyAppList,
|
||||
bypassApps = bypassApps,
|
||||
forceGoogleApps = forceGoogleApps
|
||||
)
|
||||
}
|
||||
replaceBlacklist(newBlacklist)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
|
||||
return false
|
||||
@@ -256,16 +259,8 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
|
||||
return true
|
||||
}
|
||||
|
||||
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
|
||||
if (force) {
|
||||
if (packageName == "com.google.android.webview") return false
|
||||
if (packageName.startsWith("com.google")) return true
|
||||
}
|
||||
return proxyApps.indexOf(packageName) >= 0
|
||||
}
|
||||
|
||||
private fun enablePerAppProxyAndRestart() {
|
||||
setPerAppProxyEnabled(true)
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -16,8 +17,8 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -45,6 +46,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -118,6 +120,7 @@ fun RoutingEditScreen(
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val processSelectTitle = stringResource(R.string.routing_settings_process_select)
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
var remarks by rememberSaveable { mutableStateOf(initial?.remarks ?: "") }
|
||||
@@ -176,7 +179,7 @@ fun RoutingEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.routing_settings_rule_title),
|
||||
@@ -186,14 +189,14 @@ fun RoutingEditScreen(
|
||||
IconButton(onClick = { showDeleteConfirm = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_delete_24dp),
|
||||
contentDescription = stringResource(R.string.menu_item_del_config)
|
||||
contentDescription = stringResource(R.string.acc_delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onSave(buildRuleset()) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_fab_check),
|
||||
contentDescription = stringResource(R.string.menu_item_save_config)
|
||||
contentDescription = stringResource(R.string.acc_save)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -220,21 +223,27 @@ fun RoutingEditScreen(
|
||||
checked = locked,
|
||||
onCheckedChange = { locked = it }
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.routing_settings_tips),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FormTextField(
|
||||
label = stringResource(R.string.routing_settings_domain),
|
||||
placeholder = stringResource(R.string.routing_settings_tips),
|
||||
placeholder = stringResource(R.string.routing_settings_comma_tip),
|
||||
value = domain,
|
||||
onValueChange = { domain = it }
|
||||
)
|
||||
FormTextField(
|
||||
label = stringResource(R.string.routing_settings_ip),
|
||||
placeholder = stringResource(R.string.routing_settings_tips),
|
||||
placeholder = stringResource(R.string.routing_settings_comma_tip),
|
||||
value = ip,
|
||||
onValueChange = { ip = it }
|
||||
)
|
||||
FormTextField(
|
||||
label = stringResource(R.string.routing_settings_process),
|
||||
placeholder = stringResource(R.string.routing_settings_tips),
|
||||
placeholder = stringResource(R.string.routing_settings_comma_tip),
|
||||
value = processText,
|
||||
onValueChange = { processText = it },
|
||||
enabled = canUseProcess
|
||||
@@ -251,7 +260,7 @@ fun RoutingEditScreen(
|
||||
AppPickerActivity.createIntent(
|
||||
context = context,
|
||||
selectedPackages = current,
|
||||
title = context.getString(R.string.routing_settings_process_select)
|
||||
title = processSelectTitle
|
||||
)
|
||||
)
|
||||
},
|
||||
@@ -262,7 +271,7 @@ fun RoutingEditScreen(
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.routing_settings_process_select))
|
||||
Text(processSelectTitle)
|
||||
}
|
||||
}
|
||||
FormTextField(
|
||||
@@ -284,12 +293,17 @@ fun RoutingEditScreen(
|
||||
)
|
||||
FormDropdownField(
|
||||
label = stringResource(R.string.routing_settings_outbound_tag),
|
||||
placeholder = stringResource(
|
||||
R.string.routing_settings_outbound_tag_hint,
|
||||
stringResource(R.string.server_lab_remarks)
|
||||
),
|
||||
value = outboundTag,
|
||||
options = outboundSuggestions,
|
||||
onValueChange = { outboundTag = it },
|
||||
editable = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
|
||||
@@ -3,10 +3,12 @@ package com.v2ray.ang.ui.routing
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -17,13 +19,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -46,18 +45,20 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.enums.RoutingType
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.colorConfigType
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -69,6 +70,21 @@ import kotlinx.coroutines.withContext
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
private enum class RoutingMenuAction(@StringRes val labelRes: Int) {
|
||||
ImportPredefined(R.string.routing_settings_import_predefined_rulesets),
|
||||
ImportClipboard(R.string.routing_settings_import_rulesets_from_clipboard),
|
||||
ImportQRCode(R.string.routing_settings_import_rulesets_from_qrcode),
|
||||
ExportClipboard(R.string.routing_settings_export_rulesets_to_clipboard)
|
||||
}
|
||||
|
||||
private enum class RoutingPreset(val type: RoutingType, @StringRes val labelRes: Int) {
|
||||
ChinaWhitelist(RoutingType.WHITE, R.string.routing_preset_china_whitelist),
|
||||
ChinaBlacklist(RoutingType.BLACK, R.string.routing_preset_china_blacklist),
|
||||
Global(RoutingType.GLOBAL, R.string.routing_preset_global),
|
||||
IranWhitelist(RoutingType.WHITE_IRAN, R.string.routing_preset_iran_whitelist),
|
||||
RussiaWhitelist(RoutingType.WHITE_RUSSIA, R.string.routing_preset_russia_whitelist)
|
||||
}
|
||||
|
||||
class RoutingSettingActivity : HelperBaseComponentActivity() {
|
||||
private val viewModel: RoutingSettingsViewModel by viewModels()
|
||||
private val domainStrategyState = MutableStateFlow("")
|
||||
@@ -92,7 +108,7 @@ class RoutingSettingActivity : HelperBaseComponentActivity() {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
|
||||
domainStrategyState.value = value
|
||||
},
|
||||
onImportPredefined = { index -> importPredefined(index) },
|
||||
onImportPredefined = { type -> importPredefined(type) },
|
||||
onImportClipboard = { importFromClipboard() },
|
||||
onImportQRcode = { importQRcode() },
|
||||
onExportClipboard = { export2Clipboard() }
|
||||
@@ -109,10 +125,10 @@ class RoutingSettingActivity : HelperBaseComponentActivity() {
|
||||
return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: strategies.first()
|
||||
}
|
||||
|
||||
private fun importPredefined(index: Int) {
|
||||
private fun importPredefined(type: RoutingType) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, index)
|
||||
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, type)
|
||||
launch(Dispatchers.Main) {
|
||||
viewModel.reload()
|
||||
toastSuccess(R.string.toast_success)
|
||||
@@ -172,7 +188,6 @@ class RoutingSettingActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RoutingSettingScreen(
|
||||
viewModel: RoutingSettingsViewModel,
|
||||
@@ -181,7 +196,7 @@ fun RoutingSettingScreen(
|
||||
onAddRule: () -> Unit,
|
||||
onEditRule: (Int) -> Unit,
|
||||
onDomainStrategySelected: (String) -> Unit,
|
||||
onImportPredefined: (Int) -> Unit,
|
||||
onImportPredefined: (RoutingType) -> Unit,
|
||||
onImportClipboard: () -> Unit,
|
||||
onImportQRcode: () -> Unit,
|
||||
onExportClipboard: () -> Unit
|
||||
@@ -192,8 +207,6 @@ fun RoutingSettingScreen(
|
||||
var showPresetDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val domainStrategies = stringArrayResource(R.array.routing_domain_strategy).toList()
|
||||
val presetRulesets = stringArrayResource(R.array.preset_rulesets).toList()
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
||||
// Lazy list indices include the preceding non-rule content, so resolve the stable rule keys.
|
||||
@@ -203,7 +216,7 @@ fun RoutingSettingScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.routing_settings_title),
|
||||
@@ -212,14 +225,14 @@ fun RoutingSettingScreen(
|
||||
IconButton(onClick = onAddRule) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_add_24dp),
|
||||
contentDescription = stringResource(R.string.routing_settings_add_rule)
|
||||
contentDescription = stringResource(R.string.acc_add_rule)
|
||||
)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
contentDescription = stringResource(R.string.acc_more)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -227,22 +240,15 @@ fun RoutingSettingScreen(
|
||||
onDismissRequest = { showMenu = false },
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.routing_settings_import_predefined_rulesets)) },
|
||||
onClick = { showMenu = false; showPresetDialog = true }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.routing_settings_import_rulesets_from_clipboard)) },
|
||||
onClick = { showMenu = false; onImportClipboard() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.routing_settings_import_rulesets_from_qrcode)) },
|
||||
onClick = { showMenu = false; onImportQRcode() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.routing_settings_export_rulesets_to_clipboard)) },
|
||||
onClick = { showMenu = false; onExportClipboard() }
|
||||
)
|
||||
AppDropdownMenuItems(RoutingMenuAction.entries, { it.labelRes }) { action ->
|
||||
showMenu = false
|
||||
when (action) {
|
||||
RoutingMenuAction.ImportPredefined -> showPresetDialog = true
|
||||
RoutingMenuAction.ImportClipboard -> onImportClipboard()
|
||||
RoutingMenuAction.ImportQRCode -> onImportQRcode()
|
||||
RoutingMenuAction.ExportClipboard -> onExportClipboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,7 +260,8 @@ fun RoutingSettingScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(lazyListState)
|
||||
.verticalScrollbar(lazyListState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
item(key = "domain_strategy") {
|
||||
SettingsListItem(
|
||||
@@ -301,10 +308,11 @@ fun RoutingSettingScreen(
|
||||
if (showPresetDialog) {
|
||||
SelectListDialog(
|
||||
title = stringResource(R.string.routing_settings_import_predefined_rulesets),
|
||||
options = presetRulesets,
|
||||
onSelected = { index, _ ->
|
||||
options = RoutingPreset.entries,
|
||||
optionText = { stringResource(it.labelRes) },
|
||||
onSelected = { preset ->
|
||||
showPresetDialog = false
|
||||
onImportPredefined(index)
|
||||
onImportPredefined(preset.type)
|
||||
},
|
||||
onDismiss = { showPresetDialog = false }
|
||||
)
|
||||
@@ -335,7 +343,7 @@ private fun RoutingRulesetItem(
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_lock_24dp),
|
||||
contentDescription = "Locked",
|
||||
contentDescription = stringResource(R.string.acc_locked),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -369,7 +377,7 @@ private fun RoutingRulesetItem(
|
||||
IconButton(onClick = onEdit) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_edit_24dp),
|
||||
contentDescription = "Edit"
|
||||
contentDescription = stringResource(R.string.acc_edit)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
@@ -379,7 +387,7 @@ private fun RoutingRulesetItem(
|
||||
modifier = Modifier.scale(0.7f),
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
|
||||
checkedTrackColor = colorFabActive
|
||||
checkedTrackColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -14,7 +15,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -44,6 +44,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
@@ -100,8 +101,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
protected fun CommonBasicFields(
|
||||
state: ServerUiState,
|
||||
showPort: Boolean = true
|
||||
state: ServerUiState
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FormTextField(
|
||||
@@ -114,14 +114,12 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
state.address,
|
||||
{ state.address = it }
|
||||
)
|
||||
if (showPort) {
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_port),
|
||||
state.port,
|
||||
{ state.port = it },
|
||||
keyboardType = KeyboardType.Number
|
||||
)
|
||||
}
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_port),
|
||||
state.port,
|
||||
{ state.port = it },
|
||||
keyboardType = KeyboardType.Number
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,9 +152,19 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
else -> R.string.server_lab_head_type
|
||||
}
|
||||
),
|
||||
state.headerType,
|
||||
when (state.network) {
|
||||
NetworkType.GRPC.type -> state.mode
|
||||
NetworkType.XHTTP.type -> state.xhttpMode
|
||||
else -> state.headerType
|
||||
},
|
||||
headerOptions,
|
||||
{ state.headerType = it }
|
||||
{
|
||||
when (state.network) {
|
||||
NetworkType.GRPC.type -> state.mode = it
|
||||
NetworkType.XHTTP.type -> state.xhttpMode = it
|
||||
else -> state.headerType = it
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,25 +181,26 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
else -> R.string.server_lab_request_host6
|
||||
}
|
||||
),
|
||||
state.host,
|
||||
{ state.host = it }
|
||||
if (state.network == NetworkType.GRPC.type) state.authority else state.host,
|
||||
{ if (state.network == NetworkType.GRPC.type) state.authority = it else state.host = it }
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
stringResource(
|
||||
when (state.network) {
|
||||
NetworkType.KCP.type -> R.string.server_lab_path_kcp
|
||||
NetworkType.WS.type -> R.string.server_lab_path_ws
|
||||
NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
|
||||
NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
|
||||
NetworkType.H2.type -> R.string.server_lab_path_h2
|
||||
NetworkType.GRPC.type -> R.string.server_lab_path_grpc
|
||||
else -> R.string.server_lab_path
|
||||
}
|
||||
),
|
||||
state.path,
|
||||
{ state.path = it }
|
||||
)
|
||||
if (state.network != NetworkType.KCP.type) {
|
||||
FormTextField(
|
||||
stringResource(
|
||||
when (state.network) {
|
||||
NetworkType.WS.type -> R.string.server_lab_path_ws
|
||||
NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
|
||||
NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
|
||||
NetworkType.H2.type -> R.string.server_lab_path_h2
|
||||
NetworkType.GRPC.type -> R.string.server_lab_path_grpc
|
||||
else -> R.string.server_lab_path
|
||||
}
|
||||
),
|
||||
if (state.network == NetworkType.GRPC.type) state.serviceName else state.path,
|
||||
{ if (state.network == NetworkType.GRPC.type) state.serviceName = it else state.path = it }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.network == NetworkType.XHTTP.type) {
|
||||
FormTextField(
|
||||
@@ -201,6 +210,11 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
)
|
||||
}
|
||||
if (state.network == NetworkType.KCP.type) {
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_path_kcp),
|
||||
state.seed,
|
||||
{ state.seed = it }
|
||||
)
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_kcp_mtu),
|
||||
state.kcpMtu,
|
||||
@@ -435,7 +449,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = title,
|
||||
@@ -445,14 +459,14 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
IconButton(onClick = { showDeleteDialog = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_delete_24dp),
|
||||
stringResource(R.string.menu_item_del_config)
|
||||
stringResource(R.string.acc_delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSaveClick) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_fab_check),
|
||||
stringResource(R.string.menu_item_save_config)
|
||||
stringResource(R.string.acc_save)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -468,9 +482,11 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
.verticalScroll(scrollState)
|
||||
.verticalScrollbar(scrollState)
|
||||
.padding(bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content
|
||||
)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
content()
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmDialog(
|
||||
|
||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user