mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-30 06:56:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Agent-guide adapter
|
||||
|
||||
Before starting any repository task, open and follow the repository-root `AGENTS.md`.
|
||||
That guide defines the scoped `AGENTS.md` files that must also be opened before editing
|
||||
their paths. Treat the root and applicable scoped guides as mandatory. Do not duplicate
|
||||
or replace their rules in this file.
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5.7.0
|
||||
uses: actions/setup-java@v6.0.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
@@ -21,6 +21,9 @@ V2rayNG/app/build
|
||||
V2rayNG/build
|
||||
V2rayNG/local.properties
|
||||
|
||||
# Ignore prebuilt native libraries (built from submodules by compile-hevtun.sh / AndroidLibXrayLite)
|
||||
V2rayNG/app/libs
|
||||
|
||||
# Ignore APK and AAR files
|
||||
*.apk
|
||||
*.aar
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# v2rayNG agent guide
|
||||
|
||||
## Scope and precedence
|
||||
|
||||
This file applies to the entire repository. Read the applicable scoped guide before editing any
|
||||
listed path, including when the agent starts at the repository root:
|
||||
|
||||
- Read `V2rayNG/app/src/main/java/com/v2ray/ang/service/AGENTS.md` for
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/service/`,
|
||||
`V2rayNG/app/src/test/java/com/v2ray/ang/service/`, and these paths under
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/`: `core/CoreServiceManager.kt`,
|
||||
`core/LauncherManager.kt`, `root/`, `contracts/ServiceControl.kt`,
|
||||
`contracts/Tun2SocksControl.kt`, `handler/NotificationManager.kt`,
|
||||
`helper/MessageHelper.kt`, and `helper/NotificationHelper.kt`.
|
||||
- Read `V2rayNG/app/src/main/java/com/v2ray/ang/ui/AGENTS.md` for
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/ui/` and
|
||||
`V2rayNG/app/src/test/java/com/v2ray/ang/ui/`.
|
||||
|
||||
Follow both guides in scoped paths. A scoped rule overrides a root rule only when they require
|
||||
incompatible actions; every other root rule remains active.
|
||||
|
||||
## Requirement language
|
||||
|
||||
- `must`, `must not`, `only`, and `never` are mandatory. Only the current user request or linked
|
||||
acceptance criteria can state an exception; agent inference cannot.
|
||||
- `Task scope` is the named files and behavior plus dependency, test, or build edits that a
|
||||
compiler or required validation proves necessary. Every other change is outside scope.
|
||||
- `Changed behavior` includes direct and downstream build-time or runtime effects through callers,
|
||||
consumers, storage, IPC, and resources.
|
||||
- `Supported` versions, ABIs, distributions, and locales are exactly those declared by
|
||||
`V2rayNG/app/build.gradle.kts` and `.github/workflows/build.yml` inputs.
|
||||
- A behavior is `verified` only when its named check passed on branch HEAD. Otherwise list the
|
||||
exact unrun check and blocker, and do not call the behavior verified.
|
||||
|
||||
## Existing code is not a compliance baseline
|
||||
|
||||
- These guides define the target state; they do not claim that this checkout, an older commit, or
|
||||
another worktree complies. Existing code shows current behavior and is not permission to repeat
|
||||
a pattern.
|
||||
- A `touched declaration` is any function, property, class, composable, resource entry, manifest
|
||||
element, or Gradle block containing an added or removed line. Inspect it and every caller or
|
||||
consumer whose contract changes against all applicable rules before editing.
|
||||
- Every new declaration and changed behavior must comply. Inspect a helper or pattern before
|
||||
reusing it. Tests prove only their assertions; names, green builds, and prior approval prove
|
||||
nothing else.
|
||||
- Do not repair unrelated violations. Fix a violation left on a task-changed execution path in the
|
||||
same branch; if that would change behavior outside scope, request approval first. Report any
|
||||
remaining violation that blocks required validation.
|
||||
|
||||
## Agent-guide synchronization and PR boundary
|
||||
|
||||
- `AGENTS.md` files are the only rule source. `CLAUDE.md`, `GEMINI.md`, and
|
||||
`.github/copilot-instructions.md` are routing shims and must only load or point to applicable
|
||||
`AGENTS.md` files.
|
||||
- Feature, fix, and release branches use instruction files from fetched `upstream/master` as their
|
||||
baseline. If `upstream` is absent, run
|
||||
`git remote add upstream https://github.com/2dust/v2rayNG.git`; otherwise require
|
||||
`git remote get-url upstream` to print exactly that URL. Successfully run
|
||||
`git fetch upstream master` before comparison. An agent-instruction branch uses its HEAD and
|
||||
working diff as the proposal.
|
||||
- At task start and after each upstream merge or rebase, synchronize canonical instructions into
|
||||
the worktree without deleting local-only text. Compare them with:
|
||||
|
||||
```sh
|
||||
git diff upstream/master -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
```
|
||||
|
||||
After synchronization, every displayed difference must be inside a local-only block. If a branch
|
||||
predates the files, keep copied versions untracked or unstaged; acquire tracked copies only by
|
||||
merging or rebasing their upstream commit.
|
||||
- User-, machine-, worktree-, and temporary text is local-only. Put it in `AGENTS.md` between
|
||||
visible lines `LOCAL INSTRUCTIONS - START` and `LOCAL INSTRUCTIONS - END`.
|
||||
Unless the task targets agent instructions, treat every pre-existing instruction-file difference
|
||||
as local-only.
|
||||
- Keep local-only text unstaged and uncommitted. Never use `git add .`, `git add -A`, or
|
||||
`git commit -a` in that worktree. Stage task paths explicitly; never delete, restore, or overwrite
|
||||
local instructions to clean the worktree.
|
||||
- Before each commit, push, or PR update outside agent-instruction scope, run both commands. Both
|
||||
must print no paths; otherwise stop and remove the instruction change from the staged or branch
|
||||
diff without altering its working copy.
|
||||
|
||||
```sh
|
||||
git diff --cached --name-only -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
git diff --name-only upstream/master...HEAD -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
```
|
||||
|
||||
- Commit instruction files only for a task explicitly limited to agent instructions. The branch
|
||||
and PR may contain only `AGENTS.md` and necessary routing-shim changes; name agent instructions
|
||||
in the PR title and state that exclusive scope in the body.
|
||||
|
||||
## Project boundaries
|
||||
|
||||
- The Android project is under `V2rayNG/` and uses Kotlin, Gradle Kotlin DSL, Compose, Material 3,
|
||||
coroutines, and AndroidX lifecycle. Read dependency versions from
|
||||
`V2rayNG/gradle/libs.versions.toml`, SDK levels from `V2rayNG/app/build.gradle.kts`, and CI tools
|
||||
from `.github/workflows/build.yml`; never copy those numbers into a guide.
|
||||
- `AngApplication` initializes MMKV, app locales, WorkManager, defaults, and theme state. Persist
|
||||
application data through `MmkvManager` or `SettingsManager`; do not create `SharedPreferences`
|
||||
or another path for data they own.
|
||||
- `core/` owns native configuration and lifecycle, `handler/` data and application operations,
|
||||
`service/` Android services, and `ui/` Compose activities, components, and ViewModels. Put new
|
||||
code in its owner unless a scoped guide names a narrower owner.
|
||||
- Shared lifecycle and configuration code must retain VPN, proxy-only, and root-mode branches.
|
||||
Change one mode only when task scope names it; gate the change and preserve the other modes.
|
||||
- `AndroidLibXrayLite` is native-core source. `V2rayNG/app/libs/libv2ray.aar` and the HEV libraries
|
||||
are generated inputs; do not modify or commit them unless task scope upgrades a native dependency.
|
||||
Such a PR must identify the source revision and verify every app-declared ABI.
|
||||
|
||||
## Build and validation
|
||||
|
||||
Before building a clean checkout, initialize submodules recursively and reproduce the HEV and AAR
|
||||
steps in `.github/workflows/build.yml`. The AAR revision must match the `AndroidLibXrayLite` gitlink
|
||||
from `git ls-tree HEAD AndroidLibXrayLite`. Take build-tool versions from that workflow and
|
||||
`V2rayNG/gradle/libs.versions.toml`.
|
||||
|
||||
Run Gradle from `V2rayNG/`, using `gradlew.bat` in Windows PowerShell or `./gradlew` on POSIX. Use
|
||||
the Play Store debug variant unless task scope names another distribution.
|
||||
|
||||
Apply every matching validation rule:
|
||||
|
||||
- Documentation only: run `git diff --check`; no Gradle task is required.
|
||||
- Kotlin/Java production code: run the test class that asserts each changed behavior, then
|
||||
`:app:testPlaystoreDebugUnitTest` and `:app:compilePlaystoreDebugKotlin`.
|
||||
- Resources, manifest, Gradle, dependencies, or native packaging: run
|
||||
`:app:assemblePlaystoreDebug`.
|
||||
- F-Droid only: replace `Playstore` with `Fdroid`. Run both variants when shared code branches on a
|
||||
flavor, `BuildConfig.DISTRIBUTION`, or a flavor-specific resource or dependency.
|
||||
- Android service lifecycle, framework callback, native interaction, permission, accessibility,
|
||||
focus, or state restoration: also run an emulator or physical-device check.
|
||||
|
||||
Add or update JUnit tests under `V2rayNG/app/src/test/java/` for changed deterministic logic. Report
|
||||
an unavailable required check under the exact label `Not run`, with its command or scenario and
|
||||
reason. A compile or assembled APK does not verify runtime behavior.
|
||||
|
||||
There is no enforced Kotlin formatter. Preserve local indentation and import order. Do not change
|
||||
whitespace outside touched declarations or reorder imports except as the diff requires.
|
||||
|
||||
## Repository-wide coding rules
|
||||
|
||||
- Keep the diff inside task scope; exclude unrelated refactors, upgrades, generated files,
|
||||
translation cleanup, renames, and formatting-only changes.
|
||||
- Use server GUIDs and group IDs for persistence, asynchronous work, and UI state. Never use a
|
||||
list index, adapter position, or paging position as server or group identity.
|
||||
- Run disk, network, package-manager, native, bitmap, and CPU-intensive work off the main thread.
|
||||
Own each asynchronous operation with a named lifecycle or ViewModel scope and cancel it when the
|
||||
owner ends or newer work supersedes it.
|
||||
- Put all visible and accessibility text in Android resources. Update every locale in
|
||||
`androidResources.localeFilters` for each changed key, preserving placeholders, plurals, and
|
||||
formatting tags.
|
||||
- Never rename or delete a persisted, serialized, routing, import, or export field without a
|
||||
backward-compatible migration and a regression test reading the preceding released format.
|
||||
Multi-record writes must complete together or restore/remove partial writes on every failure.
|
||||
- Log recoverable failures through `LogUtil`, including operation, mode/component, non-secret
|
||||
stable ID when available, and exception. Never log credentials, full proxy URLs, private keys,
|
||||
or exported configurations.
|
||||
- Before adding a constant, helper, manager, repository, or state holder, search its owner. Extend
|
||||
an owner that controls the same data or lifecycle. Create a new abstraction only for different
|
||||
ownership or lifetime, and state that distinction in code or the PR.
|
||||
- A platform workaround needs a comment naming its Android/API or vendor boundary, prevented
|
||||
failure, and exact removal condition.
|
||||
|
||||
## Deprecated, experimental, and version-gated APIs
|
||||
|
||||
- Introduce a deprecated API only for a supported-version fallback lacking the replacement, a
|
||||
required deprecated callback/interface member, or a documented platform/vendor defect that
|
||||
blocks acceptance criteria. The defect requires a test or linked upstream issue.
|
||||
- Isolate each permitted deprecated call in one compatibility function or adapter. Comment the
|
||||
reason and an exact removal trigger. Use the stable API wherever available.
|
||||
- Scope `@Suppress("DEPRECATION")` or `@SuppressLint("<IssueId>")` to the containing expression or function.
|
||||
Class scope is allowed only when every member implements the same required deprecated interface;
|
||||
file, package, and module scope is forbidden. Never suppress permission, background-execution,
|
||||
lifecycle, or security requirements.
|
||||
- Guard every API above `minSdk` by SDK or extension version in the same function, or use
|
||||
`@RequiresApi` only when every entry point is guarded. Mark reusable guards
|
||||
`@ChecksSdkIntAtLeast`. Lower versions must use an
|
||||
existing API, return explicit unsupported status, or hide/disable the feature; never crash or
|
||||
report false success.
|
||||
- Add or expand an experimental API only when no stable API meets named acceptance criteria, its
|
||||
dependency is pinned in `libs.versions.toml`, a project-owned stable interface contains it and
|
||||
excludes it from persisted data, IPC payloads, and public shared contracts, a regression test
|
||||
covers it, and a comment names the opt-in marker and exact reevaluation condition.
|
||||
- Put `@OptIn` on the direct function/property, or on a class only when multiple members need it.
|
||||
File and module opt-ins are forbidden. Existing experimental use outside scope does not permit a
|
||||
new call site.
|
||||
- A preview/canary SDK or alpha dependency is allowed in production only when the request or linked
|
||||
issue requires it. Keep a stable path for supported non-preview devices, gate by runtime API or
|
||||
feature availability, and test both paths. After an API guard, opt-in, or lint suppression, run
|
||||
`:app:lintPlaystoreDebug` and also `:app:lintFdroidDebug` when behavior differs by distribution.
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: b21389865e...c634d1baea.
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 743
|
||||
versionName = "2.3.3"
|
||||
versionCode = 746
|
||||
versionName = "2.3.6"
|
||||
|
||||
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))
|
||||
|
||||
@@ -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"
|
||||
@@ -200,6 +209,9 @@
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.TranslatorsActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".service.CoreVpnService"
|
||||
|
||||
@@ -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"
|
||||
@@ -178,7 +180,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
|
||||
|
||||
@@ -113,7 +113,13 @@ object CoreConfigManager {
|
||||
}
|
||||
} else {
|
||||
json.remove("stats")
|
||||
json.remove("policy")
|
||||
// Keep user-defined policy levels, only strip the stats-related system block
|
||||
json.get("policy")?.takeIf { it.isJsonObject }?.asJsonObject?.let { policy ->
|
||||
policy.remove("system")
|
||||
if (policy.entrySet().isEmpty()) {
|
||||
json.remove("policy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!needTun()) {
|
||||
@@ -709,7 +715,7 @@ object CoreConfigManager {
|
||||
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
||||
v2rayConfig.stats = null
|
||||
v2rayConfig.policy = null
|
||||
v2rayConfig.policy?.system = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -321,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,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
|
||||
}
|
||||
|
||||
@@ -374,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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,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
|
||||
}
|
||||
}
|
||||
@@ -469,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 -> {
|
||||
@@ -492,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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -15,13 +15,13 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.toSpeedString
|
||||
import com.v2ray.ang.ui.main.MainActivity
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
|
||||
internal data class StoredProfileReference(
|
||||
val guid: String,
|
||||
val subscriptionId: String?,
|
||||
)
|
||||
|
||||
internal object OrphanProfileCleaner {
|
||||
|
||||
/**
|
||||
* Finds profile payloads that are provably unreachable from the raw group indexes.
|
||||
*
|
||||
* A null subscription ID means that the profile payload could not be decoded and is
|
||||
* preserved. A null server set means that a group index could not be decoded, in which
|
||||
* case the entire classification returns null and no cleanup should run.
|
||||
* Subscription metadata is deliberately not an input because SUB and SUB_IDS can be
|
||||
* missing while the raw SUB_SERVERS_* indexes remain intact.
|
||||
*/
|
||||
fun findOrphans(
|
||||
profiles: Collection<StoredProfileReference>,
|
||||
indexedServersBySubscription: Map<String, Set<String>?>,
|
||||
selectedServer: String?,
|
||||
): Set<String>? {
|
||||
if (profiles.isEmpty()) return emptySet()
|
||||
|
||||
if (indexedServersBySubscription.isEmpty() ||
|
||||
indexedServersBySubscription.values.any { it == null }
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val indexedServers = indexedServersBySubscription.values
|
||||
.filterNotNull()
|
||||
.flatten()
|
||||
.toSet()
|
||||
|
||||
return profiles.mapNotNullTo(linkedSetOf()) { profile ->
|
||||
if (profile.guid == selectedServer || profile.guid in indexedServers) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
val subscriptionId = profile.subscriptionId ?: return@mapNotNullTo null
|
||||
val groupId = subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||
if (!indexedServersBySubscription.containsKey(groupId)) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
profile.guid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
internal object ProfileReplacement {
|
||||
|
||||
/**
|
||||
* Finds the profile that should become selected after publishing a replacement batch.
|
||||
* The first profile becomes selected when the store has no current selection.
|
||||
*/
|
||||
fun findSelectedReplacement(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
currentSelection: String?,
|
||||
selectedProfile: ProfileItem?,
|
||||
): String? {
|
||||
if (profiles.isEmpty()) return null
|
||||
if (currentSelection.isNullOrBlank()) return profiles.keys.first()
|
||||
if (selectedProfile == null) return null
|
||||
|
||||
if (selectedProfile.remarks.isNotBlank()) {
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks) &&
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
return profiles.keys.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds replaced payloads that are safe to remove.
|
||||
*
|
||||
* A null cross-group reference set means that at least one raw group index could not
|
||||
* be read. In that case deletion fails closed.
|
||||
*/
|
||||
fun findRemovablePayloads(
|
||||
replacedServers: Collection<String>,
|
||||
replacementServers: Set<String>,
|
||||
protectedServer: String?,
|
||||
serversReferencedByOtherGroups: Set<String>?,
|
||||
): Set<String> {
|
||||
if (serversReferencedByOtherGroups == null) return emptySet()
|
||||
|
||||
return replacedServers.filterTo(linkedSetOf()) { guid ->
|
||||
guid != protectedServer &&
|
||||
guid !in replacementServers &&
|
||||
guid !in serversReferencedByOtherGroups
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -388,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -13,6 +14,7 @@ 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
|
||||
@@ -21,6 +23,10 @@ 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 {
|
||||
@@ -36,7 +42,7 @@ class CoreTestService : Service() {
|
||||
)
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_stop_24dp,
|
||||
getString(android.R.string.cancel),
|
||||
getString(R.string.action_cancel),
|
||||
pendingIntent
|
||||
).build()
|
||||
}
|
||||
@@ -145,7 +151,7 @@ class CoreTestService : Service() {
|
||||
}
|
||||
|
||||
is RealPingEvent.Finish -> {
|
||||
if(message.subscriptionId.isNotEmpty()){
|
||||
if (message.subscriptionId.isNotEmpty()) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
|
||||
AngConfigManager.removeInvalidServer(message.subscriptionId)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -18,9 +18,26 @@ import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
internal object RealPingExecutionLimiter {
|
||||
private val customConfigMutex = Mutex()
|
||||
|
||||
suspend fun <T> run(configType: EConfigType, block: () -> T): T {
|
||||
// Custom profiles bypass speed-test trimming and start complete Xray configs.
|
||||
// Parallel teardown can abort the native probe process, so serialize their
|
||||
// JNI measurements globally across batches.
|
||||
return if (configType == EConfigType.CUSTOM) {
|
||||
customConfigMutex.withLock { block() }
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker that runs a batch of real-ping tests independently.
|
||||
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
|
||||
@@ -87,7 +104,7 @@ class RealPingWorkerService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRealPing(guid: String): Long {
|
||||
private suspend fun startRealPing(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
|
||||
@@ -110,7 +127,9 @@ class RealPingWorkerService(
|
||||
if (!configResult.status) {
|
||||
return retFailure
|
||||
}
|
||||
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
return RealPingExecutionLimiter.run(config.configType) {
|
||||
CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTcping(guid: String): Long {
|
||||
@@ -120,7 +139,7 @@ class RealPingWorkerService(
|
||||
if (!config.configType.isComplexType()
|
||||
&& config.configType != EConfigType.HYSTERIA2
|
||||
&& config.configType != EConfigType.WIREGUARD
|
||||
&& config.alpn?.startsWith("h3") != true
|
||||
&& config.alpn?.split(',')?.all { it.trim().startsWith("h3") } != true
|
||||
&& config.server.isNotNullEmpty()
|
||||
&& config.serverPort?.toIntOrNull() != null
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# UI code agent guide
|
||||
|
||||
These rules apply to the UI paths listed in the repository-root `AGENTS.md`. Follow both
|
||||
guides; apply the precedence rule defined by the root guide.
|
||||
|
||||
## Compose architecture and state
|
||||
|
||||
- Implement every new screen and component with the existing Compose and Material 3
|
||||
stack. A new screen activity must extend `BaseComponentActivity` or
|
||||
`HelperBaseComponentActivity` and place its screen, ViewModel, and action contract in
|
||||
the package that owns its navigation entry. Do not add a fragment, ViewBinding layout,
|
||||
XML screen layout, or RecyclerView adapter. Do not migrate existing legacy UI outside
|
||||
task scope, and do not extend it or use it as the template for new UI.
|
||||
- Keep durable screen state and business work in a ViewModel/repository and expose it as
|
||||
immutable observable state. Collect flows with `collectAsStateWithLifecycle()`.
|
||||
Compose-local state is only for transient presentation state; use `rememberSaveable`
|
||||
when that state must survive activity recreation.
|
||||
- Durable state includes loaded data, selection by domain ID, validation results,
|
||||
progress, errors, and any value written to settings or a profile. Transient
|
||||
presentation state is limited to visual expansion, animation, scroll/focus position,
|
||||
and an uncommitted input value owned solely by the visible component. A composable
|
||||
must not perform repository, MMKV, import/export, routing, or native-core work.
|
||||
- Send user operations through the owning feature's action/event contract and
|
||||
ViewModel. Main-screen operations must use `MainAction` and `MainViewModel`.
|
||||
Composables must not mutate a repository, `MmkvManager`, or `SettingsManager`
|
||||
directly.
|
||||
- Use server GUIDs and group IDs for Compose keys, selection, saved state, and action
|
||||
parameters. Do not persist or dispatch a visible index, adapter position, or paging
|
||||
offset as identity. After filtering, sorting, subscription replacement, paging, or an
|
||||
asynchronous result, resolve the item again by its domain ID.
|
||||
- Run blocking file, network, package-manager, and bitmap-decoding operations on
|
||||
`Dispatchers.IO`. Run CPU-bound parsing, sorting, filtering, and transformation on
|
||||
`Dispatchers.Default`. Launch both from the owning ViewModel or lifecycle scope.
|
||||
Publish results through a private `MutableStateFlow` exposed as `StateFlow`; do not
|
||||
read or write Compose snapshot state on either background dispatcher.
|
||||
|
||||
## Interaction, accessibility, and layout
|
||||
|
||||
- Give every icon with a click, long-click, toggle, or custom accessibility action a
|
||||
localized accessible name that states its action. Set `contentDescription = null` on
|
||||
every icon with no user action. Do not expose a raw URL,
|
||||
package ID, GUID, or duplicated descendant label when a row-level semantic node
|
||||
supplies the name.
|
||||
- A row with one activation behavior must expose one focusable semantic node, one
|
||||
localized name, its current selected/on/off state, and one activation action. Merge or
|
||||
clear descendant semantics so a label, icon, checkbox, and switch do not become
|
||||
duplicate focus targets. Keep a descendant as a separate node only when it performs a
|
||||
different user action; give that node its own name and role. TalkBack, touch, Enter,
|
||||
Space, and D-pad center must invoke the same action for the same node.
|
||||
- Key each focusable list item by its domain ID. Reordering or recomposition must keep
|
||||
focus on the node with that ID if it still exists. If a selection change removes or
|
||||
replaces the focused node, move focus exactly once to the final active item after the
|
||||
state update; do not announce or focus an intermediate item.
|
||||
- Use `stringResource` for text created inside a composable and localized
|
||||
`Context.getString`/resources outside Compose. This rule covers visible labels,
|
||||
semantic labels, state descriptions, validation messages, errors, dialogs, snackbars,
|
||||
and toasts. Apply the root guide's locale and placeholder requirements to every key.
|
||||
- Every form containing a text-editing control must use a vertically scrollable
|
||||
container. Inside a `Scaffold`, apply its `innerPadding`, call
|
||||
`consumeWindowInsets(innerPadding)`, and
|
||||
apply `imePadding()` to that container. Outside a `Scaffold`, apply `imePadding()` and
|
||||
consume the navigation-bar inset exactly once. With the IME visible, the user must be
|
||||
able to focus, read, edit, and activate both the first and last form controls without
|
||||
dismissing the IME.
|
||||
- A shared component used by server screens must preserve edge-to-edge system-bar
|
||||
insets, light and dark themes, dynamic color enabled and disabled, and both single-
|
||||
and double-column layouts. A change to such a component requires rendering and
|
||||
interaction checks in each of those configurations; dynamic-color checks require a
|
||||
device or emulator that supports dynamic color.
|
||||
|
||||
## Validation mapping
|
||||
|
||||
Apply every row whose trigger matches the UI diff:
|
||||
|
||||
- ViewModel state, action mapping, validation, filtering, parsing, or selection logic:
|
||||
add or update a JVM test that asserts the initial state and every success, invalid-
|
||||
input, empty-input, and failure branch added or modified by the diff.
|
||||
- `rememberSaveable`, `SavedStateHandle`, activity recreation, or restoration logic:
|
||||
recreate the activity and verify the final state by domain ID, not list position.
|
||||
- Loading or data presentation: exercise every state represented by the owning state
|
||||
model among empty, loading, error, and populated; do not invent states absent from
|
||||
that model.
|
||||
- Click, toggle, selection, or navigation behavior: verify touch, keyboard focus and
|
||||
activation, and D-pad focus and activation.
|
||||
- Semantics, focus, accessible text, row merging, or icon meaning: inspect the semantics
|
||||
tree and run TalkBack on an emulator or device. Record the spoken label, state, focus
|
||||
order, and activation result.
|
||||
- Form fields, scrolling, or insets: open the IME, traverse from the first control to the
|
||||
last control, and activate the primary action with the IME still visible.
|
||||
- A setting, profile, routing rule, or other persisted value: perform the action, close
|
||||
and reopen the screen, and verify the value through the owning ViewModel/repository.
|
||||
|
||||
A screenshot verifies appearance only. It does not verify semantics, focus, activation,
|
||||
state ownership, persistence, or recreation. Record every mapped check that did not run
|
||||
under `Not run`; do not claim the corresponding behavior is verified.
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -12,7 +14,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -32,6 +33,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.VersionInfoBlock
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -44,12 +46,20 @@ class AboutActivity : BaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
AboutScreen(onBackClick = { finish() })
|
||||
AboutScreen(
|
||||
onBackClick = { finish() },
|
||||
onTranslatorsClick = {
|
||||
startActivity(Intent(this, TranslatorsActivity::class.java))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AboutScreen(onBackClick: () -> Unit) {
|
||||
fun AboutScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onTranslatorsClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showOssDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -58,7 +68,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
val appIdText = BuildConfig.APPLICATION_ID
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_about),
|
||||
@@ -82,6 +92,11 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
title = stringResource(R.string.title_oss_license),
|
||||
onClick = { showOssDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_translate_24dp),
|
||||
title = stringResource(R.string.title_translators),
|
||||
onClick = onTranslatorsClick
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_feedback_24dp),
|
||||
title = stringResource(R.string.title_pref_feedback),
|
||||
@@ -101,6 +116,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
versionText = versionText,
|
||||
appIdText = appIdText
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,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 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.TranslatorsCredit
|
||||
import com.v2ray.ang.dto.TranslatorsParser
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class TranslatorsActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
TranslatorsScreen(onBackClick = { finish() })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TranslatorsScreen(onBackClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val credits = remember {
|
||||
TranslatorsParser.parse(Utils.readTextFromAssets(context, "translators.json"))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_translators),
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(credits, key = { it.language }) { credit ->
|
||||
TranslationCreditCard(credit)
|
||||
}
|
||||
item {
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TranslationCreditCard(credit: TranslatorsCredit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = credit.language,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
credit.contributors.forEachIndexed { index, contributor ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
ContributorRow(
|
||||
displayName = contributor.displayName ?: contributor.name,
|
||||
linkUrl = contributor.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContributorRow(displayName: String, linkUrl: String?) {
|
||||
val context = LocalContext.current
|
||||
val isLink = linkUrl != null
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isLink) {
|
||||
Modifier.clickable(role = Role.Button) {
|
||||
Utils.openUri(context, linkUrl)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (isLink) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_github_24dp),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (isLink) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier = Modifier.padding(start = if (isLink) 12.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -16,8 +17,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -32,10 +31,11 @@ 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.AppListItem
|
||||
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) {
|
||||
@@ -128,7 +128,7 @@ fun AppPickerScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = title,
|
||||
@@ -185,7 +185,8 @@ fun AppPickerScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = selectedPackages.contains(app.packageName)
|
||||
|
||||
@@ -41,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
|
||||
@@ -102,7 +116,7 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
|
||||
|
||||
return apps.filter {
|
||||
it.appName.contains(query, ignoreCase = true) ||
|
||||
it.packageName.contains(query, ignoreCase = true)
|
||||
it.packageName.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +136,9 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSpecialItemUnidentified(context: Context): AppInfo {
|
||||
private fun createSpecialItemUnidentified(appName: String): AppInfo {
|
||||
return AppInfo(
|
||||
appName = context.getString(R.string.app_picker_unknown_app),
|
||||
appName = appName,
|
||||
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
|
||||
isSystemApp = false,
|
||||
isSelected = 0
|
||||
|
||||
@@ -7,13 +7,13 @@ import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -38,8 +38,10 @@ import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.InputDialog
|
||||
import com.v2ray.ang.ui.compose.InputField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -105,6 +107,7 @@ class BackupActivity : HelperBaseComponentActivity() {
|
||||
BackupLocation.WebDav -> viewModel.restoreViaWebDav(cacheDir)
|
||||
}
|
||||
},
|
||||
onCleanupProfiles = viewModel::cleanupProfileStorage,
|
||||
onWebDavSave = { config -> viewModel.saveWebDavConfig(config) },
|
||||
onBackClick = { finish() }
|
||||
)
|
||||
@@ -186,6 +189,7 @@ fun BackupScreen(
|
||||
onBackupOptionSelected: (BackupLocation) -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
onRestoreOptionSelected: (BackupLocation) -> Unit,
|
||||
onCleanupProfiles: () -> Unit,
|
||||
onWebDavSave: (WebDavConfig) -> Unit,
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
@@ -193,12 +197,13 @@ fun BackupScreen(
|
||||
val currentWebDavConfig by webDavConfigState.collectAsState()
|
||||
var showBackupDialog by remember { mutableStateOf(false) }
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var showCleanupDialog by remember { mutableStateOf(false) }
|
||||
var showWebDavDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val webDavSummary = currentWebDavConfig?.baseUrl
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_configuration_backup_restore),
|
||||
@@ -228,6 +233,12 @@ fun BackupScreen(
|
||||
title = stringResource(R.string.title_configuration_restore),
|
||||
onClick = { showRestoreDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_delete_24dp),
|
||||
title = stringResource(R.string.title_profile_storage_cleanup),
|
||||
subtitle = stringResource(R.string.summary_profile_storage_cleanup),
|
||||
onClick = { showCleanupDialog = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_settings_24dp),
|
||||
@@ -235,6 +246,7 @@ fun BackupScreen(
|
||||
subtitle = webDavSummary,
|
||||
onClick = { showWebDavDialog = true }
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +274,13 @@ fun BackupScreen(
|
||||
onDismiss = { showRestoreDialog = false }
|
||||
)
|
||||
}
|
||||
if (showCleanupDialog) {
|
||||
DeleteConfirmDialog(
|
||||
message = stringResource(R.string.message_profile_storage_cleanup),
|
||||
onConfirm = onCleanupProfiles,
|
||||
onDismiss = { showCleanupDialog = false }
|
||||
)
|
||||
}
|
||||
if (showWebDavDialog) {
|
||||
WebDavInputDialog(
|
||||
initialConfig = currentWebDavConfig,
|
||||
@@ -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,14 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,4 +118,4 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,15 @@ 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
|
||||
@@ -25,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() {
|
||||
@@ -63,7 +66,7 @@ fun CheckUpdateScreen(
|
||||
val versionText = "v${BuildConfig.VERSION_NAME} ($libVersion)"
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.update_check_for_update),
|
||||
@@ -90,6 +93,7 @@ fun CheckUpdateScreen(
|
||||
onClick = { viewModel.checkForUpdates() }
|
||||
)
|
||||
VersionInfoBlock(versionText = versionText)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,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()
|
||||
@@ -109,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,21 +1,24 @@
|
||||
package com.v2ray.ang.ui.compose
|
||||
|
||||
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.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
|
||||
@@ -43,19 +46,19 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.v2ray.ang.util.AppIconFetcher
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.util.AppIconFetcher
|
||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -110,7 +113,7 @@ fun AppTopBar(
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = colorFabActive)
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +214,7 @@ fun AppListItem(
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = CheckboxDefaults.colors(checkedColor = colorFabActive)
|
||||
colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colorScheme.secondary)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -227,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,
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui.compose
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -15,6 +14,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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
|
||||
@@ -44,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
|
||||
@@ -220,7 +220,7 @@ fun <T> SelectListDialog(
|
||||
role = Role.RadioButton
|
||||
) else Modifier.clickable { onSelected(option) }
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (showRadio) {
|
||||
@@ -233,11 +233,7 @@ fun <T> SelectListDialog(
|
||||
Text(
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -246,7 +242,7 @@ fun <T> 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,
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.currentStateAsState
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
data class ScrollbarConfig(
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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,6 +8,7 @@ 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
|
||||
@@ -21,7 +22,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 +37,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 +94,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
|
||||
}
|
||||
@@ -136,7 +140,7 @@ fun LogcatScreen(
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_logcat),
|
||||
@@ -206,7 +210,8 @@ 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) })
|
||||
|
||||
@@ -75,10 +75,10 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val restartService = data.getBooleanExtra(
|
||||
ProfileEditorResult.EXTRA_RESTART_SERVICE, false
|
||||
)
|
||||
val selectedProfileSaved = action == ProfileEditorResult.ACTION_SAVED &&
|
||||
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
|
||||
mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService && mainViewModel.uiState.value.isRunning) {
|
||||
restartV2Ray()
|
||||
}
|
||||
if (restartService || selectedProfileSaved) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
private val settingsActivityLauncher =
|
||||
@@ -87,7 +87,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val refreshGroups = SettingsChangeManager.consumeSetupGroupTab()
|
||||
mainViewModel.refreshUiSettings()
|
||||
if (refreshGroups) mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService && mainViewModel.uiState.value.isRunning) restartV2Ray()
|
||||
if (restartService) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -110,7 +110,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
MainAction.ImportClipboard -> importClipboard()
|
||||
MainAction.ImportConfigLocal -> importConfigLocal()
|
||||
is MainAction.ImportManually -> importManually(action.type)
|
||||
MainAction.RestartService -> restartV2Ray()
|
||||
MainAction.RestartService -> LauncherManager.restartServiceOrStart(this, ::requestServiceStart)
|
||||
MainAction.LocateSelectedServer -> mainViewModel.triggerLocateSelectedServer()
|
||||
is MainAction.SelectServer -> setSelectServer(action.guid)
|
||||
is MainAction.EditServer -> editServer(action.guid, action.profile)
|
||||
@@ -161,14 +161,20 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
private fun handleFabAction() {
|
||||
if (mainViewModel.uiState.value.isRunning) {
|
||||
LauncherManager.stopService(this)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
|
||||
} else {
|
||||
startV2Ray()
|
||||
requestServiceStart()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestServiceStart() {
|
||||
if (!SettingsManager.isVpnMode()) {
|
||||
startV2Ray()
|
||||
return
|
||||
}
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
|
||||
}
|
||||
|
||||
private fun handleLayoutTestClick() {
|
||||
if (mainViewModel.uiState.value.isRunning) {
|
||||
mainViewModel.testCurrentServerRealPing()
|
||||
@@ -188,14 +194,6 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
LauncherManager.startService(this)
|
||||
}
|
||||
|
||||
private fun restartV2Ray() {
|
||||
if (mainViewModel.uiState.value.isRunning) LauncherManager.stopService(this)
|
||||
lifecycleScope.launch {
|
||||
kotlinx.coroutines.delay(500)
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun importManually(createConfigType: Int) {
|
||||
val intent = when (createConfigType) {
|
||||
EConfigType.POLICYGROUP.value -> Intent(this, ServerGroupActivity::class.java)
|
||||
@@ -275,7 +273,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val selected = mainViewModel.uiState.value.selectedGuid
|
||||
if (guid != selected) {
|
||||
mainViewModel.updateSelectedGuid(guid)
|
||||
if (mainViewModel.uiState.value.isRunning) restartV2Ray()
|
||||
LauncherManager.restartService(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -17,7 +18,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -25,6 +25,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.ui.compose.AppDivider
|
||||
@@ -40,26 +42,29 @@ fun MainBottomBar(
|
||||
onAction: (MainAction) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable(onClick = { onAction(MainAction.TestCurrentServer) })
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
) {
|
||||
AppDivider()
|
||||
Surface(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.height(64.dp)
|
||||
.clickable(onClick = { onAction(MainAction.TestCurrentServer) }),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 0.dp
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = displayText, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = displayText
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package com.v2ray.ang.ui.main
|
||||
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DrawerState
|
||||
@@ -21,15 +23,15 @@ 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.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) {
|
||||
@@ -71,12 +73,14 @@ fun MainDrawerContent(drawerState: DrawerState, onNavigate: (MainDestination) ->
|
||||
drawerContainerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(drawerScrollState).verticalScrollbar(drawerScrollState)
|
||||
modifier = Modifier
|
||||
.verticalScroll(drawerScrollState)
|
||||
.verticalScrollbar(drawerScrollState)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp)
|
||||
.height(180.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -85,13 +89,21 @@ fun MainDrawerContent(drawerState: DrawerState, onNavigate: (MainDestination) ->
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults
|
||||
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.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
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 +36,34 @@ 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,
|
||||
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
|
||||
)
|
||||
},
|
||||
divider = {}
|
||||
val selectedIndex = selectedTabIndex.coerceIn(0, groups.lastIndex)
|
||||
val listState = rememberLazyListState(initialFirstVisibleItemIndex = selectedIndex)
|
||||
|
||||
LaunchedEffect(selectedIndex) {
|
||||
if (listState.layoutInfo.visibleItemsInfo.none { it.index == selectedIndex }) {
|
||||
listState.animateScrollToItem(selectedIndex)
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
groups.forEachIndexed { index, group ->
|
||||
itemsIndexed(
|
||||
items = groups,
|
||||
key = { _, group -> group.id },
|
||||
) { 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,26 +74,39 @@ 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()
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
text = {
|
||||
val text = if (group.id.isEmpty()) {
|
||||
group.remarks
|
||||
} else {
|
||||
"${group.remarks} (${servers.size})"
|
||||
val text = if (group.id.isEmpty()) {
|
||||
group.remarks
|
||||
} else {
|
||||
"${group.remarks} (${servers.size})"
|
||||
}
|
||||
|
||||
Box(Modifier.widthIn(min = 56.dp)) {
|
||||
Tab(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = 48.dp),
|
||||
text = {
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (selected) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.secondary)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.ui.compose.LocalDarkTheme
|
||||
import com.v2ray.ang.ui.compose.QRCodeDialog
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -44,7 +43,7 @@ fun MainScreen(
|
||||
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
|
||||
@@ -73,16 +72,12 @@ fun MainScreen(
|
||||
val lazyListStates = remember { mutableStateMapOf<String, LazyListState>() }
|
||||
val lazyGridStates = remember { mutableStateMapOf<String, LazyGridState>() }
|
||||
|
||||
var locateInProgress by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(groups) {
|
||||
val validGroupIds = groups.map { it.id }.toSet()
|
||||
lazyListStates.keys.retainAll(validGroupIds)
|
||||
lazyGridStates.keys.retainAll(validGroupIds)
|
||||
}
|
||||
|
||||
val latestDoubleColumnDisplay by rememberUpdatedState(doubleColumnDisplay)
|
||||
|
||||
LaunchedEffect(groups, uiState.selectedGroupId) {
|
||||
if (groups.isEmpty()) return@LaunchedEffect
|
||||
val selectedIndex = groups.indexOfFirst { it.id == uiState.selectedGroupId }
|
||||
@@ -93,68 +88,18 @@ fun MainScreen(
|
||||
}
|
||||
|
||||
val latestGroups by rememberUpdatedState(groups)
|
||||
val latestLocateInProgress by rememberUpdatedState(locateInProgress)
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }
|
||||
.distinctUntilChanged()
|
||||
.collect { page ->
|
||||
val currentGroups = latestGroups
|
||||
if (!latestLocateInProgress && page in currentGroups.indices) {
|
||||
if (page in currentGroups.indices) {
|
||||
onAction(MainAction.SelectGroup(currentGroups[page].id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.locateTarget) {
|
||||
val target = uiState.locateTarget ?: return@LaunchedEffect
|
||||
if (target.groupIndex !in 0 until pagerState.pageCount) {
|
||||
mainViewModel.onAction(MainAction.LocateHandled(target))
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
locateInProgress = true
|
||||
try {
|
||||
if (pagerState.settledPage != target.groupIndex) {
|
||||
pagerState.navigateToPageOptimized(
|
||||
targetPage = target.groupIndex,
|
||||
animateAdjacentPage = false
|
||||
)
|
||||
}
|
||||
onAction(MainAction.SelectGroup(target.groupId))
|
||||
|
||||
repeat(10) {
|
||||
val ready = if (latestDoubleColumnDisplay) {
|
||||
lazyGridStates[target.groupId] != null
|
||||
} else {
|
||||
lazyListStates[target.groupId] != null
|
||||
}
|
||||
if (ready) return@repeat
|
||||
delay(16L)
|
||||
}
|
||||
|
||||
if (latestDoubleColumnDisplay) {
|
||||
lazyGridStates[target.groupId]?.let { gridState ->
|
||||
gridState.scrollToItem(
|
||||
index = target.itemPosition,
|
||||
scrollOffset = -gridState.layoutInfo.viewportSize.height / 3
|
||||
)
|
||||
}
|
||||
} else {
|
||||
lazyListStates[target.groupId]?.let { listState ->
|
||||
listState.scrollToItem(
|
||||
index = target.itemPosition,
|
||||
scrollOffset = -listState.layoutInfo.viewportSize.height / 3
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
delay(32L)
|
||||
locateInProgress = false
|
||||
mainViewModel.onAction(MainAction.LocateHandled(target))
|
||||
}
|
||||
}
|
||||
|
||||
MainDialogs(
|
||||
showDelAllConfirm = showDelAllConfirm,
|
||||
onDismissDelAll = { showDelAllConfirm = false },
|
||||
@@ -279,8 +224,8 @@ fun MainScreen(
|
||||
groupId = group.id,
|
||||
mainViewModel = mainViewModel,
|
||||
selectedGuid = selectedGuid,
|
||||
locateTarget = uiState.locateTarget,
|
||||
doubleColumnDisplay = doubleColumnDisplay,
|
||||
confirmRemove = confirmRemove,
|
||||
searchQuery = searchQuery,
|
||||
lazyListStates = lazyListStates,
|
||||
lazyGridStates = lazyGridStates,
|
||||
|
||||
@@ -30,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()
|
||||
|
||||
@@ -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,14 +125,7 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
is MainServiceEvent.MeasureConfigNotify -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
statusText = dataSource.getString(
|
||||
R.string.connection_running_task_left,
|
||||
event.progress
|
||||
)
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
|
||||
}
|
||||
|
||||
is MainServiceEvent.MeasureConfigFinish -> {
|
||||
@@ -145,16 +134,58 @@ class MainViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -175,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) }
|
||||
@@ -206,7 +237,7 @@ class MainViewModel(
|
||||
viewModelScope.launch(preloadDispatcher) {
|
||||
try {
|
||||
initialPageReady.await()
|
||||
delay(32L)
|
||||
delay(32)
|
||||
dataSource.initAssets()
|
||||
dataSource.syncSubscriptions()
|
||||
} catch (cancelled: CancellationException) {
|
||||
@@ -235,8 +266,7 @@ class MainViewModel(
|
||||
ServersCache(
|
||||
guid = guid,
|
||||
profile = profile.copy(),
|
||||
testDelayMillis = affiliation?.testDelayMillis ?: 0L,
|
||||
testDelayString = affiliation?.getTestDelayString().orEmpty()
|
||||
testDelayMillis = affiliation?.testDelayMillis ?: 0L
|
||||
)
|
||||
}
|
||||
|
||||
@@ -274,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()
|
||||
@@ -319,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() }
|
||||
@@ -349,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)
|
||||
}
|
||||
@@ -567,7 +622,7 @@ class MainViewModel(
|
||||
|
||||
fun subscriptionIdChanged(id: String) {
|
||||
if (_uiState.value.groups.none { it.id == id }) return
|
||||
mutableServersForGroup(id)
|
||||
mutableServerGroupState(id)
|
||||
if (uiState.value.selectedGroupId != id) {
|
||||
dataSource.setSelectedSubscriptionId(id)
|
||||
_uiState.update { it.copy(selectedGroupId = id) }
|
||||
@@ -602,7 +657,7 @@ class MainViewModel(
|
||||
}
|
||||
order.forEachIndexed { index, groupId ->
|
||||
ensureActive()
|
||||
if (index > 0) delay(32L)
|
||||
if (index > 0) delay(32)
|
||||
updateGroupUi(groupId, loadGroup(groupId, forceRefresh = true))
|
||||
}
|
||||
}
|
||||
@@ -613,7 +668,7 @@ class MainViewModel(
|
||||
keywordFilter = keyword
|
||||
filterJob?.cancel()
|
||||
filterJob = viewModelScope.launch(defaultDispatcher) {
|
||||
delay(300L)
|
||||
delay(300)
|
||||
val snapshot = cacheMutex.withLock { groupDataCache.toMap() }
|
||||
ensureActive()
|
||||
snapshot.forEach { (groupId, servers) ->
|
||||
@@ -645,10 +700,13 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
fun moveServer(groupId: String, fromPosition: Int, toPosition: Int) {
|
||||
val servers = mutableServersForGroup(groupId).value.toMutableList()
|
||||
val groupState = mutableServerGroupState(groupId).value
|
||||
val servers = groupState.servers.toMutableList()
|
||||
if (!servers.moveItem(fromPosition, toPosition)) return
|
||||
val rows = groupState.rows.toMutableList()
|
||||
rows.moveItem(fromPosition, toPosition)
|
||||
val guids = servers.map { it.guid }
|
||||
mutableServersForGroup(groupId).value = servers
|
||||
mutableServerGroupState(groupId).value = ServerGroupUiState(servers, rows)
|
||||
// A drag emits several moves; serialize writes so an older order cannot overwrite a newer one.
|
||||
val previousPersistenceJob = serverOrderPersistenceJobs[groupId]
|
||||
serverOrderPersistenceJobs[groupId] = viewModelScope.launch(ioDispatcher) {
|
||||
@@ -665,7 +723,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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -674,25 +732,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
|
||||
)
|
||||
)
|
||||
@@ -700,11 +771,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()
|
||||
}
|
||||
|
||||
@@ -715,7 +782,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 })
|
||||
@@ -726,24 +793,21 @@ class MainViewModel(
|
||||
val selected = dataSource.getSelectServer() ?: return
|
||||
val profile = dataSource.decodeServerConfig(selected) ?: return
|
||||
val groupId = profile.subscriptionId
|
||||
val groupIndex =
|
||||
_uiState.value.groups.indexOfFirst { it.id == groupId }.takeIf { it >= 0 } ?: return
|
||||
if (_uiState.value.groups.none { it.id == groupId }) return
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
val position =
|
||||
loadGroup(groupId).indexOfFirst { it.guid == selected }.takeIf { it >= 0 }
|
||||
?: return@launch
|
||||
updateGroupUi(groupId, loadGroup(groupId))
|
||||
if (_uiState.value.selectedGroupId != groupId) {
|
||||
dataSource.setSelectedSubscriptionId(groupId)
|
||||
}
|
||||
val target = LocateTarget(groupId, selected)
|
||||
_uiState.update {
|
||||
it.copy(locateTarget = LocateTarget(groupId, groupIndex, position))
|
||||
it.copy(selectedGroupId = groupId, locateTarget = target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPosition(guid: String): Int = currentServers().indexOfFirst { it.guid == guid }
|
||||
|
||||
private fun consumeLocateTarget(target: LocateTarget) {
|
||||
_uiState.update { state ->
|
||||
if (state.locateTarget == target) state.copy(locateTarget = null) else state
|
||||
}
|
||||
private fun consumeLocateTarget() {
|
||||
_uiState.update { it.copy(locateTarget = null) }
|
||||
}
|
||||
|
||||
// ---------- Running state ----------
|
||||
@@ -751,8 +815,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
@@ -49,7 +49,7 @@ import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppListItem
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -137,7 +137,7 @@ fun PerAppProxyScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.per_app_proxy_settings),
|
||||
@@ -168,7 +168,7 @@ fun PerAppProxyScreen(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
contentDescription = stringResource(R.string.acc_more)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -224,7 +224,7 @@ fun PerAppProxyScreen(
|
||||
onCheckedChange = onPerAppProxyChanged,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
|
||||
checkedTrackColor = colorFabActive
|
||||
checkedTrackColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -245,7 +245,7 @@ fun PerAppProxyScreen(
|
||||
onCheckedChange = onBypassAppsChanged,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
|
||||
checkedTrackColor = colorFabActive
|
||||
checkedTrackColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -264,7 +264,8 @@ fun PerAppProxyScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = blacklist.contains(app.packageName)
|
||||
|
||||
@@ -124,7 +124,7 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
|
||||
|
||||
return apps.filter {
|
||||
it.appName.contains(query, ignoreCase = true) ||
|
||||
it.packageName.contains(query, ignoreCase = true)
|
||||
it.packageName.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -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(
|
||||
@@ -294,6 +303,7 @@ fun RoutingEditScreen(
|
||||
editable = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -22,7 +23,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -54,11 +54,11 @@ import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.colorConfigType
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -216,7 +216,7 @@ fun RoutingSettingScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.routing_settings_title),
|
||||
@@ -232,7 +232,7 @@ fun RoutingSettingScreen(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
contentDescription = stringResource(R.string.acc_more)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -260,7 +260,8 @@ fun RoutingSettingScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(lazyListState)
|
||||
.verticalScrollbar(lazyListState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
item(key = "domain_strategy") {
|
||||
SettingsListItem(
|
||||
@@ -386,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,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,
|
||||
@@ -484,9 +482,11 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
.verticalScroll(scrollState)
|
||||
.verticalScrollbar(scrollState)
|
||||
.padding(bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content
|
||||
)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
content()
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmDialog(
|
||||
|
||||
@@ -3,9 +3,12 @@ package com.v2ray.ang.ui.server
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -13,7 +16,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -339,7 +341,7 @@ fun ServerScreen(
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = configType.toString(),
|
||||
@@ -367,7 +369,7 @@ fun ServerScreen(
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.imePadding()
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = PaddingValues(bottom = 36.dp),
|
||||
contentPadding = PaddingValues(bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item { FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it }) }
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -25,7 +26,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -66,6 +66,7 @@ import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.horizontalScrollbar
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -310,7 +311,7 @@ fun ServerCustomConfigScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.CUSTOM.toString(),
|
||||
@@ -478,6 +479,7 @@ fun ServerCustomConfigScreen(
|
||||
.horizontalScrollbar(scrollState = horizontalScroll)
|
||||
)
|
||||
}
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.v2ray.ang.ui.server
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -11,7 +12,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -39,6 +39,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
|
||||
class ServerGroupActivity : BaseComponentActivity() {
|
||||
@@ -237,7 +238,7 @@ fun ServerGroupScreen(
|
||||
val supportsObservatory = BalancerStrategyType.from(selectedType).supportsObservatory
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.POLICYGROUP.toString(),
|
||||
@@ -297,6 +298,7 @@ fun ServerGroupScreen(
|
||||
editable = true
|
||||
)
|
||||
}
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class ServerHysteria2Activity : BaseServerActivity() {
|
||||
title = serverConfigType.toString(),
|
||||
onSaveClick = { saveServer(uiState) }
|
||||
) {
|
||||
CommonBasicFields(uiState, showPort = false)
|
||||
CommonBasicFields(uiState)
|
||||
Hysteria2ProtocolFields(uiState)
|
||||
|
||||
}
|
||||
@@ -91,11 +91,22 @@ class ServerHysteria2Activity : BaseServerActivity() {
|
||||
state.sni,
|
||||
{ state.sni = it }
|
||||
)
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_ech_config_list),
|
||||
state.echConfigList,
|
||||
{ state.echConfigList = it }
|
||||
)
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_pinned_ca256),
|
||||
state.pinnedCA256,
|
||||
{ state.pinnedCA256 = it }
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
stringResource(R.string.server_lab_final_mask),
|
||||
state.finalMask,
|
||||
{ state.finalMask = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import android.os.Bundle
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,7 +23,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -220,7 +222,7 @@ fun ProxyChainScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.PROXYCHAIN.toString(),
|
||||
@@ -263,7 +265,7 @@ fun ProxyChainScreen(
|
||||
top = 8.dp,
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 36.dp
|
||||
bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item(key = "remarks_field") {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.v2ray.ang.ui.settings
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -11,10 +13,11 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -25,6 +28,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
|
||||
import com.v2ray.ang.handler.MmkvManager.rememberMmkvString
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
@@ -32,6 +37,7 @@ import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.CollapsiblePreferenceGroupHeader
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsEditItem
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
@@ -73,8 +79,8 @@ fun SettingsScreen(
|
||||
var muxSettingsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var fragmentSettingsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var observatorySettingsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var advancedSettingsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var modeSettingsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var advancedSettingsExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
var modeSettingsExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
var localDns by rememberMmkvBool(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
|
||||
var fakeDns by rememberMmkvBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
|
||||
@@ -120,8 +126,13 @@ fun SettingsScreen(
|
||||
var confirmRemove by rememberMmkvBool(AppConfig.PREF_CONFIRM_REMOVE, false)
|
||||
var doubleColumnDisplay by rememberMmkvBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
|
||||
var groupAllDisplay by rememberMmkvBool(AppConfig.PREF_GROUP_ALL_DISPLAY, false)
|
||||
var language by rememberMmkvString(AppConfig.PREF_LANGUAGE, "auto")
|
||||
var language by remember {
|
||||
mutableStateOf(
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE, "auto") ?: "auto"
|
||||
)
|
||||
}
|
||||
var uiModeNight by rememberMmkvString(AppConfig.PREF_UI_MODE_NIGHT, "0")
|
||||
var dynamicColor by rememberMmkvBool(AppConfig.PREF_DYNAMIC_COLOR, true)
|
||||
|
||||
var ipv6Enabled by rememberMmkvBool(AppConfig.PREF_IPV6_ENABLED, false)
|
||||
var preferIpv6 by rememberMmkvBool(AppConfig.PREF_PREFER_IPV6, false)
|
||||
@@ -144,6 +155,14 @@ fun SettingsScreen(
|
||||
val effectiveLocalProxy = enableLocalProxy || localProxyForced
|
||||
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: 8
|
||||
|
||||
val dynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
LaunchedEffect(dynamicColorSupported) {
|
||||
if (!dynamicColorSupported && dynamicColor) {
|
||||
dynamicColor = false
|
||||
ThemeManager.setDynamicColorEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
val languageEntries = stringArrayResource(R.array.language_select).toList()
|
||||
val languageValues = stringArrayResource(R.array.language_select_value).toList()
|
||||
val uiModeNightEntries = stringArrayResource(R.array.ui_mode_night).toList()
|
||||
@@ -168,7 +187,7 @@ fun SettingsScreen(
|
||||
val modeValues = stringArrayResource(R.array.mode_value).toList()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_settings),
|
||||
@@ -220,12 +239,25 @@ fun SettingsScreen(
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
}
|
||||
)
|
||||
SettingsSwitchItem(
|
||||
title = stringResource(R.string.title_pref_dynamic_color),
|
||||
summary = stringResource(R.string.summary_pref_dynamic_color),
|
||||
checked = dynamicColor,
|
||||
enabled = dynamicColorSupported,
|
||||
onCheckedChange = {
|
||||
dynamicColor = it
|
||||
ThemeManager.setDynamicColorEnabled(it)
|
||||
}
|
||||
)
|
||||
SettingsListItem(
|
||||
title = stringResource(R.string.title_language),
|
||||
entries = languageEntries,
|
||||
values = languageValues,
|
||||
selectedValue = language,
|
||||
onSelected = { language = it }
|
||||
onSelected = {
|
||||
language = it
|
||||
AppLocaleManager.setApplicationLanguage(it)
|
||||
}
|
||||
)
|
||||
SettingsListItem(
|
||||
title = stringResource(R.string.title_pref_ui_mode_night),
|
||||
@@ -646,6 +678,7 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -19,7 +20,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -34,6 +34,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -127,7 +128,7 @@ fun TaskerScreen(
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = "",
|
||||
@@ -154,7 +155,8 @@ fun TaskerScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(items) { index, remarks ->
|
||||
Row(
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.v2ray.ang.ui.subscription
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -12,7 +13,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,6 +40,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -163,7 +164,7 @@ fun SubEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_sub_setting),
|
||||
@@ -227,7 +228,8 @@ fun SubEditScreen(
|
||||
value = prevProfile,
|
||||
options = profileSuggestions,
|
||||
onValueChange = { prevProfile = it },
|
||||
editable = true
|
||||
editable = true,
|
||||
supportingText = stringResource(R.string.sub_setting_entry_proxy_tip)
|
||||
)
|
||||
FormDropdownField(
|
||||
label = stringResource(R.string.sub_setting_next_profile),
|
||||
@@ -235,8 +237,10 @@ fun SubEditScreen(
|
||||
value = nextProfile,
|
||||
options = profileSuggestions,
|
||||
onValueChange = { nextProfile = it },
|
||||
editable = true
|
||||
editable = true,
|
||||
supportingText = stringResource(R.string.sub_setting_exit_proxy_tip)
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -47,11 +47,11 @@ import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.QRCodeDialog
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -127,7 +127,7 @@ fun SubSettingScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_sub_setting),
|
||||
@@ -149,7 +149,8 @@ fun SubSettingScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(lazyListState)
|
||||
.verticalScrollbar(lazyListState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = subscriptions,
|
||||
@@ -233,7 +234,7 @@ fun SubSettingScreen(
|
||||
modifier = Modifier.scale(0.7f),
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
|
||||
checkedTrackColor = colorFabActive
|
||||
checkedTrackColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -323,12 +324,12 @@ fun SubSettingScreen(
|
||||
showUpdateDialog = false
|
||||
onSubUpdate()
|
||||
}) {
|
||||
Text(text = stringResource(android.R.string.ok))
|
||||
Text(text = stringResource(R.string.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showUpdateDialog = false }) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
Text(text = stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -25,10 +26,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -57,6 +56,7 @@ import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -75,13 +75,14 @@ private enum class AddAssetMenuAction(@StringRes val labelRes: Int) {
|
||||
QRCode(R.string.menu_item_scan_qrcode)
|
||||
}
|
||||
|
||||
private data class AssetDeleteTarget(val guid: String, val name: String)
|
||||
|
||||
class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
|
||||
private val viewModel: UserAssetViewModel by viewModels()
|
||||
val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||
private val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||
private val isLoadingState = MutableStateFlow(false)
|
||||
private val geoFilesSourceState = MutableStateFlow("")
|
||||
private val refreshTrigger = MutableStateFlow(0)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -90,12 +91,13 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
val isLoading by isLoadingState.collectAsStateWithLifecycle()
|
||||
val geoFilesSource by geoFilesSourceState.collectAsStateWithLifecycle()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
UserAssetScreen(
|
||||
viewModel = viewModel,
|
||||
extDir = extDir,
|
||||
isLoadingState = isLoadingState,
|
||||
geoFilesSourceState = geoFilesSourceState,
|
||||
refreshTrigger = refreshTrigger,
|
||||
uiState = uiState,
|
||||
isLoading = isLoading,
|
||||
geoFilesSource = geoFilesSource,
|
||||
geoFilesSourcesList = AppConfig.GEO_FILES_SOURCES.toList(),
|
||||
onBackClick = { finish() },
|
||||
onGeoSourceSelected = { value ->
|
||||
@@ -110,13 +112,10 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
onEditAsset = { guid ->
|
||||
startActivity(Intent(this, UserAssetUrlActivity::class.java).putExtra("assetId", guid))
|
||||
},
|
||||
onRemoveAsset = { guid ->
|
||||
val asset = viewModel.getAssets().find { it.guid == guid }
|
||||
if (asset != null) {
|
||||
extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }?.delete()
|
||||
MmkvManager.removeAssetUrl(guid)
|
||||
initAssets()
|
||||
}
|
||||
onRemoveAsset = { guid, name ->
|
||||
extDir.listFiles()?.find { it.name == name }?.delete()
|
||||
MmkvManager.removeAssetUrl(guid)
|
||||
initAssets()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -208,28 +207,28 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
|
||||
private fun downloadGeoFiles() {
|
||||
refreshData()
|
||||
isLoadingState.value = true
|
||||
toast(R.string.msg_downloading_content)
|
||||
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.successCount))
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
refreshData()
|
||||
isLoadingState.value = false
|
||||
lifecycleScope.launch {
|
||||
refreshData().join()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||
}
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_asset_count, result.successCount))
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
refreshData().join()
|
||||
isLoadingState.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun initAssets() {
|
||||
private fun initAssets() {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
SettingsManager.initAssets(this@UserAssetActivity, assets)
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -238,19 +237,14 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshData() {
|
||||
viewModel.reload(getGeoFilesSources())
|
||||
refreshTrigger.value++
|
||||
}
|
||||
private fun refreshData() = viewModel.reload(getGeoFilesSources(), extDir)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserAssetScreen(
|
||||
viewModel: UserAssetViewModel,
|
||||
extDir: File,
|
||||
isLoadingState: MutableStateFlow<Boolean>,
|
||||
geoFilesSourceState: MutableStateFlow<String>,
|
||||
refreshTrigger: MutableStateFlow<Int>,
|
||||
internal fun UserAssetScreen(
|
||||
uiState: UserAssetUiState,
|
||||
isLoading: Boolean,
|
||||
geoFilesSource: String,
|
||||
geoFilesSourcesList: List<String>,
|
||||
onBackClick: () -> Unit,
|
||||
onGeoSourceSelected: (String) -> Unit,
|
||||
@@ -259,19 +253,14 @@ fun UserAssetScreen(
|
||||
onAddQrcodeClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onEditAsset: (String) -> Unit,
|
||||
onRemoveAsset: (String) -> Unit
|
||||
onRemoveAsset: (String, String) -> Unit
|
||||
) {
|
||||
val isLoading by isLoadingState.collectAsState()
|
||||
val geoFilesSource by geoFilesSourceState.collectAsState()
|
||||
val assets by viewModel.assetsFlow.collectAsStateWithLifecycle()
|
||||
val trigger by refreshTrigger.collectAsState()
|
||||
|
||||
var showAddMenu by remember { mutableStateOf(false) }
|
||||
var deleteTargetGuid by remember { mutableStateOf<String?>(null) }
|
||||
var deleteTarget by remember { mutableStateOf<AssetDeleteTarget?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_user_asset_setting),
|
||||
@@ -311,9 +300,10 @@ fun UserAssetScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
item(key = "geo_source_$trigger") {
|
||||
item(key = "geo_source") {
|
||||
SettingsListItem(
|
||||
title = stringResource(R.string.asset_geo_files_sources),
|
||||
entries = geoFilesSourcesList,
|
||||
@@ -329,12 +319,14 @@ fun UserAssetScreen(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
itemsIndexed(items = assets, key = { _, item -> "${item.guid}_$trigger" }) { _, item ->
|
||||
itemsIndexed(items = uiState.assets, key = { _, item -> item.guid }) { _, item ->
|
||||
UserAssetItem(
|
||||
item = item,
|
||||
extDir = extDir,
|
||||
fileMetadata = uiState.fileMetadata[item.guid],
|
||||
onEdit = { onEditAsset(item.guid) },
|
||||
onDeleteClick = { deleteTargetGuid = item.guid }
|
||||
onDeleteClick = {
|
||||
deleteTarget = AssetDeleteTarget(item.guid, item.assetUrl.remarks)
|
||||
}
|
||||
)
|
||||
ItemDivider()
|
||||
}
|
||||
@@ -342,13 +334,14 @@ fun UserAssetScreen(
|
||||
}
|
||||
|
||||
|
||||
if (deleteTargetGuid != null) {
|
||||
val guid = deleteTargetGuid!!
|
||||
val assetName = assets.find { it.guid == guid }?.assetUrl?.remarks ?: ""
|
||||
deleteTarget?.let { asset ->
|
||||
DeleteConfirmDialog(
|
||||
message = stringResource(R.string.confirm_delete_asset_file, assetName),
|
||||
onConfirm = { onRemoveAsset(guid) },
|
||||
onDismiss = { deleteTargetGuid = null }
|
||||
message = stringResource(R.string.confirm_delete_asset_file, asset.name),
|
||||
onConfirm = {
|
||||
deleteTarget = null
|
||||
onRemoveAsset(asset.guid, asset.name)
|
||||
},
|
||||
onDismiss = { deleteTarget = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -356,16 +349,15 @@ fun UserAssetScreen(
|
||||
@Composable
|
||||
private fun UserAssetItem(
|
||||
item: AssetUrlCache,
|
||||
extDir: File,
|
||||
fileMetadata: AssetFileMetadata?,
|
||||
onEdit: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
val file = remember(item.guid, item.assetUrl.remarks) {
|
||||
extDir.listFiles()?.find { it.name == item.assetUrl.remarks }
|
||||
}
|
||||
val propertiesText = if (file != null) {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
|
||||
val propertiesText = if (fileMetadata != null) {
|
||||
remember(fileMetadata) {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
"${fileMetadata.length.toTrafficString()} • ${dateFormat.format(Date(fileMetadata.lastModified))}"
|
||||
}
|
||||
} else {
|
||||
stringResource(R.string.msg_file_not_found)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ package com.v2ray.ang.ui.userasset
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -28,6 +28,7 @@ import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
@@ -142,7 +143,7 @@ fun UserAssetUrlScreen(
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_user_asset_add_url),
|
||||
@@ -182,6 +183,7 @@ fun UserAssetUrlScreen(
|
||||
value = url,
|
||||
onValueChange = { url = it }
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.v2ray.ang.ui.userasset
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
@@ -11,30 +12,42 @@ import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
internal data class AssetFileMetadata(val length: Long, val lastModified: Long)
|
||||
|
||||
internal data class UserAssetUiState(
|
||||
val assets: List<AssetUrlCache> = emptyList(),
|
||||
val fileMetadata: Map<String, AssetFileMetadata> = emptyMap()
|
||||
)
|
||||
|
||||
class UserAssetViewModel(application: Application) : BaseViewModel(application) {
|
||||
private val assets = mutableListOf<AssetUrlCache>()
|
||||
private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||
|
||||
private val _assetsFlow = MutableStateFlow<List<AssetUrlCache>>(emptyList())
|
||||
val assetsFlow: StateFlow<List<AssetUrlCache>> = _assetsFlow.asStateFlow()
|
||||
private val _uiState = MutableStateFlow(UserAssetUiState())
|
||||
internal val uiState: StateFlow<UserAssetUiState> = _uiState.asStateFlow()
|
||||
private var reloadJob: Job? = null
|
||||
|
||||
val itemCount: Int
|
||||
get() = assets.size
|
||||
|
||||
fun getAssets(): List<AssetUrlCache> = assets.toList()
|
||||
|
||||
fun getAsset(position: Int): AssetUrlCache? = assets.getOrNull(position)
|
||||
|
||||
fun reload(geoFilesSource: String) {
|
||||
val decoded = MmkvManager.decodeAssetUrls()
|
||||
assets.clear()
|
||||
assets.addAll(buildAssetList(decoded, geoFilesSource))
|
||||
_assetsFlow.value = assets.toList()
|
||||
fun reload(geoFilesSource: String, extDir: File): Job {
|
||||
reloadJob?.cancel()
|
||||
return viewModelScope.launch(Dispatchers.IO) {
|
||||
val snapshot = buildAssetList(MmkvManager.decodeAssetUrls(), geoFilesSource)
|
||||
val files = extDir.listFiles().orEmpty().associateBy { it.name }
|
||||
val metadata = snapshot.mapNotNull { asset ->
|
||||
files[asset.assetUrl.remarks]?.let { file ->
|
||||
asset.guid to AssetFileMetadata(file.length(), file.lastModified())
|
||||
}
|
||||
}.toMap()
|
||||
ensureActive()
|
||||
_uiState.value = UserAssetUiState(snapshot, metadata)
|
||||
}.also { reloadJob = it }
|
||||
}
|
||||
|
||||
private fun buildAssetList(
|
||||
@@ -74,7 +87,7 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
|
||||
proxyUsername: String? = null,
|
||||
proxyPassword: String? = null
|
||||
): GeoDownloadResult {
|
||||
val snapshot = getAssets()
|
||||
val snapshot = uiState.value.assets
|
||||
var successCount = 0
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
@@ -127,4 +140,4 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
|
||||
val failureCount: Int,
|
||||
val failedAssets: List<String>
|
||||
)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user