Compare commits

...
44 Commits
Author SHA1 Message Date
2dust bd21bbc3f9 up 2.3.9 2026-09-22 14:49:01 +08:00
2dust 6e5d742e40 Fix
https://github.com/2dust/v2rayNG/issues/6240
2026-09-22 14:48:05 +08:00
2dust 4472162614 Preserve groups when replacing profiles 2026-09-22 14:30:22 +08:00
2dust 1f7cef32dc Animate start FAB icon spin on tap 2026-09-22 11:11:59 +08:00
2dust 7e68e2eaad Adjust toast background opacity
Increase toast background opacity from 70% to 85% for all theme variants in Theme.kt. This improves readability and keeps toast notifications visually consistent without changing their content or behavior.
2026-09-22 10:42:01 +08:00
2dust 4c7c964e65 Show service start failure details
Pass the optional failure message from the service layer through `MainServiceEvent` and show it in `MainViewModel` when available. This preserves the existing generic error toast as a fallback while surfacing more specific startup failures to the user.
2026-09-22 10:41:31 +08:00
2dust 1bd1bceadb Delete ServerActivity.kt 2026-09-21 19:13:13 +08:00
2dust 0e80f6b431 Add snackbar-safe toast dispatch
Refactor toast helpers to route through a shared dispatcher that first tries the app snackbar manager and falls back to Android Toasts on the main thread. This adds a `long` duration option across normal/success/error/info toast APIs and avoids posting to a new Handler for every message by reusing a single main-thread handler.
2026-09-21 19:10:33 +08:00
2dust 1ad5f30ede Show inline validation errors in forms
Move several save-time validation failures from toast messages to field error states in the server and subscription editors. Password validation now marks the relevant server form field, and subscription editing highlights invalid URLs and update intervals before save instead of relying on transient toasts.
2026-09-21 19:10:10 +08:00
2dust e4c0b09e1b Show inline errors in Compose forms
Replace toast-only required-field validation with Compose text field error states across routing, server, subscription, and user asset editors. The shared form field now supports `isError` and supporting text so save actions can block invalid input and highlight missing remarks, address, port, and URL values directly in the form.
2026-09-21 17:00:54 +08:00
Eliot the Cougar 073376e46d Localize notification-channel names (#6195)
Replace hard-coded channel labels with dedicated resource names for the main service, subscription updates, and connection checks in all nine supported locales. Resolve names through AppLocaleManager for service and application contexts.

Reuse the existing channel when refreshing its localized name, preserving channel IDs and user-controlled importance, sound, vibration, and visibility. Apply initial defaults only to newly created channels and remove the obsolete hard-coded service-channel name.

Extract channel localization from local/talkback-all-10 without adding its transient-message channel, snackbar fallback, or accessibility announcement infrastructure.
2026-09-21 14:55:32 +08:00
Miheichev Aleksandr SergeevichandClaude Opus 5 08c2a4deb1 refactor(ui): migrate Coil 2 to Coil 3 (#6248)
Coil 2 had its last release in July 2024 (2.7.0); Coil 3 is the
maintained line and uses new coordinates, io.coil-kt.coil3, with the
package prefix coil3.

AppIconFetcher now returns ImageFetchResult with drawable.asImage(),
since Coil 3 models images through its own Image type. Fetcher,
Fetcher.Factory, Options, DataSource and the ImageRequest builder chain
keep the same shapes, so the rest is imports.

No network artifact is required: AppListItem is the only AsyncImage call
site and both callers pass icon = null, so every model is the local
appicon: request served by AppIconFetcher.

Coil 3.6.3 declares minSdkVersion 23 and ships no native code, so
neither minSdk 24 nor the four app ABIs are affected.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 14:49:06 +08:00
wttommy-ctrl 60ab1af384 fix(root): enforce command deadlines while draining output (#6241)
* fix(root): bound command execution and output collection

* test(root): cover command deadlines and output limits
2026-09-21 14:47:18 +08:00
cinand2dust c48fbd3fc8 feat(wireguard): add remoteDNS field to server config UI (#6230)
Expose the `remoteDNS` option (added in Xray-core 26.9.9) in the wireguard.

- Accepts a comma-separated list of IPv4/IPv6 addresses
- Accepts "local" as a single value to resolve via the device's
  local DNS instead of routing lookups through the tunnel
- Falls back to WIREGUARD_LOCAL_REMOTE_DNS when the field is empty
  (Xray-core upstream value)

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-09-21 14:42:56 +08:00
Eliot the Cougar b98895219a Fix WebView Browser Dialer recovery after network handovers (#6229)
* Confine WebView dialer lifecycle to its main looper

Preserve synchronous start/stop completion and failure propagation for worker-thread network reloads. Supersede queued lifecycle requests and prevent callbacks from reviving destroyed views. Add JVM and real-WebView regression coverage for issue #6228.

* Keep WebView device tests focused on framework thread ownership

* Remove WebView dialer instrumentation tests
2026-09-21 14:32:15 +08:00
solokot 0f65b1d348 Update Russian translation [reviewed] (#6210)
* Update Russian translation

Some improvements

* fix typo

* Update Russian translation [reviewed]
2026-09-21 11:50:22 +08:00
Eliot the Cougar a1cfa2aa7a UI/UX: Add item names to deletion confirmations (#6202)
* Name the targets in deletion confirmations

Extract the visible named-confirmation wording from the accessibility assembly onto current upstream. Cover main-list profiles, standard and custom profile editors, policy groups, proxy-chain members, subscriptions, routing-rule editors, and asset sources. Reuse the six localized named variants in all nine catalogs; preserve existing generic strings and the already-named asset-file confirmation.

Carry display names alongside existing GUIDs in main/subscription list confirmation requests, and retain the main target across recreation with its two-string saver. Use saved names in editors rather than uncommitted renames. Do not import accessibility semantics, action menus, live regions, the separate routing-list Delete button, or the proxy-chain stable-key migration.

Validation: 72 JVM tests passed, including two deletion-target saver regressions; Play Store debug compilation and x86_64 assembly passed. An API 33 emulator verified all 54 compiled localized resources with Unicode and percent-containing names, the main profile confirmation across rotation, and subscription confirmation/cancel. No deletion was confirmed. Other editors, double-column menus, keyboard/D-pad, and TalkBack were not exercised. Original APK and rotation settings were restored.

* Standardize named delete confirmation layout

Center delete confirmation messages while retaining a single Text node for the question and target. Separate each item name with a blank line in all nine locales, including asset files, which previously embedded the filename in the question.

Keep the title-free dialog and existing Cancel/Delete actions, and leave ordinary confirmation messages start-aligned. Bulk-delete prompts retain their collective wording.

Validation: 72 JVM tests, Kotlin compilation and Play Store debug assembly passed. All 63 compiled localized templates preserved an exact Unicode/percent target. Emulator checks covered profile and asset message nodes, two action targets, cancellation and profile dialog recreation after rotation. Actual spoken TalkBack output and the full theme/input matrix were not verified.

* Resolve named proxy-chain confirmations by stable member key

Upstream #6200 replaced pending member indices with stable keys. The textual rebase of #6202 retained an out-of-scope index reference in the confirmation text, breaking compilation.

Look up the current member name through memberKeys, using the same pending identity as deletion. Reordering no longer separates the displayed target from the member removed on confirmation.

* Reuse deletion questions instead of duplicating named translations

Let DeleteConfirmDialog append an optional item name after a blank line while keeping the complete message in one centered Text. Reuse the existing localized questions at all named deletion sites and remove six duplicate resource keys from all nine locales. Asset-file questions use the same shared layout. Unnamed and bulk prompts remain unchanged.

Retain stable-key proxy-chain target lookup and the corrected Russian policy-group wording in the reused question. Validation: existing ServerDeleteTargetTest, all 79 JVM tests, x86_64 Play Store debug assembly and a Pixel 9 Pro emulator check passed. The profile dialog exposes one question/name text node plus Cancel/Delete, and canceling preserves the lab profiles. TalkBack was enabled for the smoke check; actual speech and the full device/theme/locale matrix were not independently verified.
2026-09-21 11:49:32 +08:00
dependabot[bot] 53fce9a8f5 Bump actions/setup-java from 6.0.0 to 6.0.1 (#6232)
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v6.0.0...v6.0.1)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 16:01:23 +08:00
2dust cb39a10ada Upgrade Gradle, AGP, and core dependencies 2026-09-19 15:56:26 +08:00
Mohammad c1c93b62d1 Add finalMask parsing to Wireguard configuration handling (#6242) 2026-09-19 15:02:56 +08:00
eliotcougar a9b1242686 Coalesce bulk probe result updates (#6205) 2026-09-13 09:51:57 +08:00
eliotcougar 173f60a1da Keep connection-test requests tied to their lifecycle (#6203)
Extract request ownership and cancellation from the accessibility work without changing result wording or presentation. Correlate current and bulk replies with the request that produced them so older progress, cancellation, and completion cannot finish a replacement test.

Own current probes in the core manager and cancel them on replacement, stop, or reload. Use the existing ordered service acknowledgement to retire requests when the daemon is absent. Cancel pending bulk preparation when the UI cancels or its ViewModel is cleared, and snapshot the filtered target list before asynchronous preparation.

Tag bulk cancellation per worker, including service destruction, instead of reporting cancellation as successful completion or issuing an untagged reply. Report rejected bulk-service starts through the same cancellation path. Preserve the existing service modes, IPC payload classes, and group post-test policy.

Validation: 74 JVM tests, Kotlin compilation and Play Store debug assembly passed. External emulator probes exercised current and bulk replacement, stale replies, cancellation during preparation, daemon-absent acknowledgement, and real notification-command cancellation over 15 Lab profiles. VPN smoke testing returned a current result with IP information and preserved disconnected status after stop. Root/proxy-only and network-handover runtime checks were not run.
2026-09-11 19:46:40 +08:00
DHR60 b9c2decb34 Delete redundant fields (#6216) 2026-09-11 19:32:23 +08:00
2dust 200c59f773 up 2.3.8 2026-09-10 14:23:35 +08:00
eliotcougar 49835a5cdb Use completed-action wording and plurals for update results (#6196)
Extract the config and asset update-result resources from local/talkback-all-10 in all nine supported locales. Describe completed updates instead of an update command, and select singular or plural forms using the actual config or successful asset count.

Route both subscription update entry points through the existing localized ViewModel context and use activity resources for asset results. Preserve update execution, counts, partial-failure summaries, error handling, and toast delivery; no accessibility announcement infrastructure is included.
2026-09-10 13:56:49 +08:00
2dust 53c6506308 Update AndroidLibXrayLite 2026-09-10 12:19:02 +08:00
eliotcougar 01b47581c0 Keep proxy-chain deletion targets tied to stable row keys (#6200)
Store the pending member key instead of its list position and resolve that key when deletion is confirmed. Remove the member and its corresponding row key together; if the target no longer exists, leave the draft unchanged.

Extract the deletion fix from the accessibility assembly without importing accessibility actions, dialog wording changes, or drag refactors. Reuse the same key-based removal for blank members.

Validation: 75 JVM tests passed, including five deletion regressions; Play Store debug Kotlin compilation and x86_64 assembly passed. Emulator smoke check covered confirmation across rotation and immediate blank-member removal. Keyboard/D-pad and runtime reorder-during-confirmation scenarios were not run.
2026-09-10 12:16:17 +08:00
eliotcougar 362e541cb6 Keep built-in asset row identities stable across reloads (#6199)
Use a builtin: filename-based ID instead of generating a fresh UUID each time the asset list is rebuilt. This lets the existing GUID-keyed list and file-metadata map recognize the same built-in asset after reloads and download-source changes.

Extract the fix and its two JVM regression tests from local/talkback-all-10. Preserve stored asset GUIDs, matching built-in replacement, locked flags, and URL behavior. No persisted IDs, accessibility semantics, metadata formatting, or editor behavior are changed.
2026-09-10 11:50:59 +08:00
eliotcougar 157acc8daa Format asset metadata with localized file sizes and minute precision (#6197)
Extract the visible asset metadata formatting from local/talkback-all-10. Use Android file-size formatting and derive the date/time pattern from the app configuration locale and the device 12/24-hour preference, without seconds.

Remove the metadata-only remember cache so recomposition uses the current formatting context. Preserve the existing size/date separator, missing-file message, row layout, action buttons, and accessibility semantics. No update-result or asset-editor changes are included.
2026-09-10 11:49:29 +08:00
eliotcougar 5d76fcd0fa Generate subscription QR codes off the UI thread (#6194)
Move subscription QR bitmap generation from the synchronous share callback into the existing ViewModel, running CPU-bound encoding on Dispatchers.Default and exposing the completed bitmap through StateFlow.

Cancel pending result delivery when the dialog is dismissed, a newer share request replaces it, or the ViewModel is cleared. Keep dialog rendering lifecycle-aware and reuse the existing localized failure message when generation returns no bitmap.

Extract only QR generation and dialog-state wiring from local/talkback-all-10. Leave subscription row accessibility, custom actions, update handling, and clipboard sharing unchanged.
2026-09-10 11:35:59 +08:00
eliotcougar 4004cee279 UI/UX: Preserve asset-editor drafts during recompose (#6193)
* Preserve asset-editor drafts and handle the keyboard

Keep the asset name, URL, and delete-confirmation visibility in saveable Compose state so activity recreation does not discard an unfinished edit. Key the state by asset identity and initial field values.

Make the form vertically scrollable, consume the Scaffold padding, and apply IME padding so its fields remain reachable when the keyboard reduces the available height. Extract these presentation changes from local/talkback-all-10 without changing accessibility labels, deletion wording, or asset-save validation order.

* Resize the asset editor when the keyboard opens

Declare stateUnchanged|adjustResize for UserAssetUrlActivity, matching the other editor activities. Without an explicit resize policy, opening the IME in landscape pans the entire window and moves the toolbar Save action off-screen despite the scrollable Compose form.

Verified on Android 13 Pixel 6a: rotate with an unsaved draft, reopen the landscape keyboard, scroll between fields, edit the first field, save without hiding the keyboard, and reopen persisted values. Portrait keyboard and Save also pass. Debug assembly succeeded.
2026-09-10 11:34:20 +08:00
eliotcougar 06bcd4c16b Show per-app proxy help in a dialog (#6192)
Replace the short-lived info toast with the existing titleless confirmation dialog and a single OK button so users can read the routing explanation at their own pace. Keep dialog visibility in saveable screen state and reuse the existing localized help and action strings.

Extract only the help-dialog presentation from local/talkback-all-10; leave app-row semantics, switches, routing descriptions, and notification infrastructure in the accessibility branches.
2026-09-10 11:33:58 +08:00
eliotcougar 8d61aedeca fix: validate asset edits before deleting the cached file (#6190)
Reject duplicate names and empty names or URLs before invalidating the existing asset cache. Previously a rejected save left the stored source unchanged but deleted its downloaded file.

Preserve the validation messages and their order, duplicate-name exclusion by asset ID, and successful-save behavior. Renames still invalidate the old filename before updating the asset record.
2026-09-10 11:33:22 +08:00
2dust 2020807c25 up 2.3.7 2026-09-05 15:52:22 +08:00
2dust ca4185abad Rename daemon process and split task services
Standardizes manifest process names by replacing the long `:RunSoLibV2RayDaemon` suffix with `:daemon` for shortcut activities, VPN/proxy services, and related receivers. It also moves `CoreTestService` and `SubscriptionUpdateService` to a separate `:tasks` process to isolate task-oriented work from the main daemon process.
2026-09-05 15:47:34 +08:00
eliotcougar 8d9152bb4e Align default settings hints with actual app behavior (#6183) 2026-09-05 15:06:19 +08:00
eliotcougar 5bfcdf90ec Clarify boot auto-connect and link system VPN settings (#6179)
* Clarify device-boot connection and link system VPN settings

* Match Bakhtiari system spelling in VPN settings strings
2026-09-05 14:59:06 +08:00
eliotcougar f4f992eb9b Deduplicate subscription IDs when reading the saved index (#6172)
A saved SUB_IDS array can contain the same GUID more than once. Loading
it verbatim creates repeated subscription rows with identical Compose
and reorderable-list keys, which can crash the subscription screen.

Deduplicate IDs in the shared reader while preserving the first-seen
order and mutable-list contract. This protects every index consumer
without changing subscription records, generating replacement IDs, or
adding writes during reads. Different subscriptions with identical or
blank names remain separate.

Add six regression tests for duplicate rows, stable ordering, read-only
decoding, mutable round trips, repeated names, and existing empty/invalid
input behavior. Three assertions fail on unchanged upstream; all six
pass with the fix. The full 63-test suite and Play Store debug build pass.

On the API 37.1 Pixel 9 Pro emulator, a deliberately duplicated saved
index loaded each subscription once, retained first-occurrence order,
reopened safely, and edited the correct record. The raw index remained
duplicated throughout the check; no app crash was observed.
2026-09-05 14:54:46 +08:00
2dust 1af8f4677c Revert "Use ruleset IDs for routing edits"
This reverts commit c548e5f615.
2026-09-05 14:50:28 +08:00
eliotcougar 4d8ee1a916 Fix Logcat crashes on duplicate log lines (#6170)
Logcat used the complete log text as a Compose lazy-list key after 258de2097. Identical timestamped messages and repeated stack frames are valid log output, but collide as keys and crash the main thread while browsing logs.

Give each occurrence its own key in a small ViewModel-owned presentation model. Count oldest-first so prepending newer logs and filtering unrelated lines retain existing occurrence keys. Build the projection off the main thread and keep raw text, duplicates, ordering, copy and share behavior unchanged.

Add six regression tests covering duplicate messages, repeated frames, filtering, refresh, empty input and key-like text. All 63 Play Store unit tests pass; the debug APK was checked on the API 37.1 emulator with duplicate-line scrolling, filtering, refresh, reopening and lossless export. The original failure is documented by the user-provided physical-device crash log.
2026-09-05 14:46:10 +08:00
eliotcougar 95bcf32c0b Fix Logcat refresh button navigation inset (#6155) 2026-09-05 14:26:39 +08:00
2dust c548e5f615 Use ruleset IDs for routing edits
Switch routing ruleset lookup, save, and delete flows from list index to stable ruleset ID. Routing edit intents now pass `ruleset_id`, editing reloads by ID, and list item edit actions use each rule’s ID. This avoids index mismatch bugs when list order changes and keeps update/delete targeting the correct ruleset.
2026-08-30 19:38:43 +08:00
2dust 258de20972 Use stable list keys in Logcat and Tasker 2026-08-30 19:29:03 +08:00
2dust 3dc0173b9f Fix
https://github.com/2dust/v2rayNG/issues/6153
2026-08-30 17:44:53 +08:00
92 changed files with 2281 additions and 1426 deletions

No files matched your search

+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
out-file-path: V2rayNG/app/libs/
- name: Setup Java
uses: actions/setup-java@v6.0.0
uses: actions/setup-java@v6.0.1
with:
distribution: 'temurin'
java-version: '21'
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 746
versionName = "2.3.6"
versionCode = 749
versionName = "2.3.9"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
+12 -11
View File
@@ -153,6 +153,7 @@
android:exported="false" />
<activity
android:name=".ui.userasset.UserAssetUrlActivity"
android:windowSoftInputMode="stateUnchanged|adjustResize"
android:exported="false" />
<activity
@@ -166,19 +167,19 @@
android:name=".ui.shortcut.ScSwitchActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.shortcut.ScStartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.shortcut.ScStopActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
@@ -220,7 +221,7 @@
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
tools:ignore="VpnServicePolicy">
<intent-filter>
<action android:name="android.net.VpnService" />
@@ -238,7 +239,7 @@
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
@@ -249,7 +250,7 @@
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
@@ -259,7 +260,7 @@
android:name=".service.CoreTestService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
android:process=":tasks">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
@@ -269,7 +270,7 @@
android:name=".service.SubscriptionUpdateService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
android:process=":tasks">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
@@ -278,7 +279,7 @@
<receiver
android:name=".receiver.WidgetProvider"
android:exported="true"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/app_widget_provider" />
@@ -306,7 +307,7 @@
android:icon="@drawable/ic_stat_name"
android:label="@string/app_tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:process=":RunSoLibV2RayDaemon">
android:process=":daemon">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
@@ -327,7 +328,7 @@
<receiver
android:name=".receiver.TaskerReceiver"
android:exported="true"
android:process=":RunSoLibV2RayDaemon"
android:process=":daemon"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
+71 -91
View File
@@ -1,106 +1,86 @@
{
"stats":{},
"stats": {},
"log": {
"loglevel": "warning"
},
"policy":{
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
"policy": {
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [
{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"address": "v2ray.cool",
"port": 10086
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
}
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
"tag": "direct"
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "v2ray.cool",
"port": 10086,
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"alterId": 0,
"security": "auto",
"level": 8
}
]
}
],
"servers": [
{
"address": "v2ray.cool",
"method": "chacha20",
"ota": false,
"password": "123456",
"port": 10086,
"level": 8
}
]
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
},
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": []
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
"hosts": {},
"servers": []
"hosts": {},
"servers": []
}
}
}
@@ -1,123 +1,103 @@
{
"stats":{},
"stats": {},
"log": {
"loglevel": "warning"
},
"policy":{
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
"policy": {
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [
{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
},
{
"tag": "tun",
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1500,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [
{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"address": "v2ray.cool",
"port": 10086
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
}
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
"tag": "direct"
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
},
{
"tag": "tun",
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1500,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls",
"quic"
]
}
}
],
"outbounds": [{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "v2ray.cool",
"port": 10086,
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"alterId": 0,
"security": "auto",
"level": 8
}
]
}
],
"servers": [
{
"address": "v2ray.cool",
"method": "chacha20",
"ota": false,
"password": "123456",
"port": 10086,
"level": 8
}
]
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP",
"happyEyeballs": {
"tryDelayMs": 250,
"interleave": 2
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
},
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": []
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
"hosts": {},
"servers": []
"hosts": {},
"servers": []
}
}
}
@@ -166,8 +166,16 @@ object AppConfig {
const val WIREGUARD_LOCAL_ADDRESS_V4 = "172.16.0.2/32"
const val WIREGUARD_LOCAL_ADDRESS_V6 = "2606:4700:110:8f81:d551:a0:532e:a2b3/128"
const val WIREGUARD_LOCAL_MTU = "1420"
const val WIREGUARD_LOCAL_REMOTE_DNS = "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001"
const val LOOPBACK = "127.0.0.1"
/** Shared defaults for settings shown in the UI and consumed by config generation. */
const val DEFAULT_SOCKS_ENABLE_UDP = true
const val DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD = "1"
const val DEFAULT_VPN_BYPASS_LAN = "1"
const val DEFAULT_HEV_TUNNEL_LOGLEVEL = "warn"
const val DEFAULT_MUX_XUDP_CONCURRENCY = "8"
/** Message constants for communication. */
const val MSG_REGISTER_CLIENT = 1
const val MSG_STATE_RUNNING = 11
@@ -181,6 +189,7 @@ object AppConfig {
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
const val MSG_MEASURE_DELAY_RESULT = 61
const val MSG_MEASURE_DELAY_CANCEL = 62
const val MSG_MEASURE_CONFIG_START = 7
const val MSG_MEASURE_CONFIG_CANCEL = 71
const val MSG_MEASURE_CONFIG_SUCCESS = 72
@@ -193,7 +202,6 @@ object AppConfig {
/** Notification channel IDs and names. */
// Use a new ID because Android does not let an app raise an existing channel's importance.
const val RAY_NG_CHANNEL_ID = "CORE_M_CH_ID_V2"
const val RAY_NG_CHANNEL_NAME = "Core Background Service"
/** Protocols Scheme **/
const val VMESS = "vmess://"
@@ -517,7 +517,7 @@ object CoreConfigManager {
inbound1.listen = AppConfig.LOOPBACK
}
inbound1.port = socksPort
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
if (socksUsername != null && socksPassword != null) {
inbound1.settings?.auth = "password"
inbound1.settings?.accounts = listOf(
@@ -1058,7 +1058,7 @@ object CoreConfigManager {
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
*/
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "1") {
return
}
@@ -62,7 +62,7 @@ object CoreOutboundBuilder {
if (muxEnabled) {
outbound.mux?.enabled = true
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY).orEmpty().toInt()
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
outbound.mux?.concurrency = -1
@@ -252,15 +252,33 @@ object CoreOutboundBuilder {
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
}
val rawDNS = profileItem.remoteDNS
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.ifEmpty { null }
?: listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS)
val remotes = if (rawDNS.size == 1 && rawDNS[0] == "local") {
rawDNS
} else if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
rawDNS
} else {
val ipv4Dns = rawDNS.filter { !it.contains(":") }
ipv4Dns.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS) }
}
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = addresses
wireguard.port = null
wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
}
wireguard.mtu = profileItem.mtu
wireguard.remoteDNS = remotes
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
@@ -667,7 +685,7 @@ object CoreOutboundBuilder {
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "2") {
return domain
}
//Resolve and replace domain
@@ -31,8 +31,13 @@ import com.v2ray.ang.service.NetworkMonitor
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.ProcessFinder
@@ -47,6 +52,7 @@ object CoreServiceManager {
private var processFinder: XrayProcessFinder? = null
private var browserDialer: IDialerService? = null
private var networkMonitor: NetworkMonitor? = null
private val connectionTestScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var isReloading = false
@@ -184,6 +190,7 @@ object CoreServiceManager {
* @return True if the core was stopped successfully, false otherwise.
*/
fun stopCoreLoop(): Boolean {
connectionTestScope.coroutineContext.cancelChildren()
val service = getService() ?: return false
networkMonitor?.unregister()
@@ -254,6 +261,7 @@ object CoreServiceManager {
val tunFd = currentVpnInterface
isReloading = true
connectionTestScope.coroutineContext.cancelChildren()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
coreController.stopLoop()
@@ -308,13 +316,15 @@ object CoreServiceManager {
* Tests with primary URL first, then falls back to alternative URL if needed.
* Also fetches remote IP information if the delay test was successful.
*/
private fun measureV2rayDelay() {
if (!isRunning()) {
private fun measureV2rayDelay(requestId: String) {
val service = getService() ?: return
if (!isRunning() || isReloading) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
return
}
CoroutineScope(Dispatchers.IO).launch {
val service = getService() ?: return@launch
connectionTestScope.coroutineContext.cancelChildren()
connectionTestScope.launch {
var time = -1L
var errorStr = ""
@@ -325,6 +335,7 @@ object CoreServiceManager {
errorStr = e.message?.substringAfter("\":").orEmpty()
}
if (time == -1L) {
ensureActive()
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
@@ -333,6 +344,7 @@ object CoreServiceManager {
}
}
ensureActive()
val endpoint = if (time >= 0) SpeedtestManager.getRemoteIPInfo() else null
val result = ConnectionTestResult(
delayMillis = time,
@@ -340,7 +352,17 @@ object CoreServiceManager {
country = endpoint?.country,
ipAddress = endpoint?.ipAddress,
)
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result)
withContext(Dispatchers.Main.immediate) {
if (isRunning()) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result, requestId)
} else {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
}
}
}.invokeOnCompletion { cause ->
if (cause is CancellationException) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
}
}
}
@@ -478,7 +500,8 @@ object CoreServiceManager {
}
AppConfig.MSG_MEASURE_DELAY -> {
measureV2rayDelay()
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
measureV2rayDelay(intent.getStringExtra("content").orEmpty())
}
}
@@ -0,0 +1,9 @@
package com.v2ray.ang.dto
import java.io.Serializable
/** One persisted delay-test result delivered from the task process to the UI. */
data class RealPingResult(
val guid: String,
val delayMillis: Long,
) : Serializable
@@ -98,6 +98,7 @@ data class V2rayConfig(
val peers: List<WireGuardBean>? = null,
var reserved: List<Int>? = null,
var mtu: Int? = null,
var remoteDNS: List<String>? = null,
var domainStrategy: String? = null,
) {
data class WireGuardBean(
@@ -56,6 +56,7 @@ data class ProfileItem(
var localAddress: String? = null,
var reserved: String? = null,
var mtu: Int? = null,
var remoteDNS: String? = null,
var obfsPassword: String? = null,
var portHopping: String? = null,
@@ -1,22 +1,25 @@
package com.v2ray.ang.enums
import androidx.annotation.StringRes
import com.v2ray.ang.R
/**
* Enum defining different notification channels.
* Each channel has a unique channelId, notificationId, and display name.
*/
enum class NotificationChannelType(
val channelId: String,
val channelName: String,
@StringRes val channelNameRes: Int,
val notificationId: Int
) {
SUBSCRIPTION_UPDATE(
channelId = "subscription_update_channel",
channelName = "Subscription Update Service",
channelNameRes = R.string.notification_channel_subscription_updates,
notificationId = 13
),
CORE_TEST(
channelId = "core_test_channel",
channelName = "Core Test Service",
channelNameRes = R.string.notification_channel_connection_checks,
notificationId = 12
)
}
}
@@ -7,111 +7,20 @@ import android.widget.Toast
import com.v2ray.ang.ui.compose.AppSnackbarManager
import com.v2ray.ang.ui.compose.ToastType
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toast(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.NORMAL) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toast(message: CharSequence) {
dispatchMessage(message, ToastType.NORMAL) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastSuccess(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.SUCCESS) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastSuccess(message: CharSequence) {
dispatchMessage(message, ToastType.SUCCESS) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastError(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.ERROR) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
dispatchMessage(message, ToastType.ERROR) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastInfo(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.INFO) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastInfo(message: CharSequence) {
dispatchMessage(message, ToastType.INFO) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private inline fun runOnMain(crossinline block: () -> Unit) {
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
Handler(Looper.getMainLooper()).post { block() }
mainHandler.post(block)
}
}
private inline fun dispatchMessage(
private fun Context.dispatchMessage(
message: CharSequence,
type: ToastType,
long: Boolean = false,
crossinline fallback: () -> Unit
long: Boolean = false
) {
val handledBySnackbar = AppSnackbarManager.show(
message = message,
@@ -119,6 +28,90 @@ private inline fun dispatchMessage(
long = long
)
if (!handledBySnackbar) {
runOnMain { fallback() }
val appContext = applicationContext
val duration = if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
runOnMain {
Toast.makeText(appContext, message, duration).show()
}
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toast(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.NORMAL, long)
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toast(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.NORMAL, long)
}
/**
* Shows a success toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastSuccess(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.SUCCESS, long)
}
/**
* Shows a success toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastSuccess(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.SUCCESS, long)
}
/**
* Shows an error toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastError(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.ERROR, long)
}
/**
* Shows an error toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastError(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.ERROR, long)
}
/**
* Shows an info toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastInfo(message: Int, long: Boolean = false) {
dispatchMessage(getString(message), ToastType.INFO, long)
}
/**
* Shows an info toast message with the given text.
*
* @param message The text of the message to show.
* @param long Whether to display the message for a longer duration.
*/
fun Context.toastInfo(message: CharSequence, long: Boolean = false) {
dispatchMessage(message, ToastType.INFO, long)
}
@@ -32,7 +32,9 @@ object WireguardFmt : FmtBase() {
config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.remoteDNS = queryParam["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
config.reserved = queryParam["reserved"] ?: "0,0,0"
config.finalMask = (queryParam["fm"] ?: queryParam["finalmask"] ?: queryParam["finalMask"])?.nullIfBlank()
return config
}
@@ -81,6 +83,7 @@ object WireguardFmt : FmtBase() {
config.remarks = System.currentTimeMillis().toString()
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.remoteDNS = interfaceParams["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
val endpoint = peerParams["endpoint"].orEmpty()
@@ -93,6 +96,7 @@ object WireguardFmt : FmtBase() {
config.serverPort = ""
}
config.reserved = peerParams["reserved"] ?: "0,0,0"
config.finalMask = (peerParams["finalmask"] ?: peerParams["fm"] ?: interfaceParams["finalmask"] ?: interfaceParams["fm"])?.nullIfBlank()
return config
}
@@ -118,6 +122,10 @@ object WireguardFmt : FmtBase() {
if (config.preSharedKey != null) {
dicQuery["presharedkey"] = config.preSharedKey.removeWhiteSpace().orEmpty()
}
if (config.remoteDNS != null) {
dicQuery["dns"] = config.remoteDNS.removeWhiteSpace().orEmpty()
}
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
return toUri(config, config.secretKey, dicQuery)
}
@@ -20,6 +20,7 @@ import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.isGroupType
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
import com.v2ray.ang.dto.entities.SubscriptionCache
@@ -349,7 +350,9 @@ object MmkvManager {
val serverList = if (append) {
decodeServerList(subscriptionId)
} else {
mutableListOf()
replacedServers.filter { guid ->
decodeServerConfig(guid)?.configType?.isGroupType() == true
}.toMutableList()
}
val indexedServers = serverList.toHashSet()
profiles.keys.forEach { guid ->
@@ -373,7 +376,7 @@ object MmkvManager {
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
val removablePayloads = ProfileReplacement.findRemovablePayloads(
replacedServers = replacedServers,
replacementServers = profiles.keys,
replacementServers = serverList.toSet(),
protectedServer = protectedServer,
serversReferencedByOtherGroups = referencedByOtherGroups,
)
@@ -718,7 +721,8 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
// Keep the first occurrence so a damaged index cannot produce duplicate Compose keys.
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.distinct()?.toMutableList() ?: mutableListOf()
}
}
@@ -1,7 +1,6 @@
package com.v2ray.ang.handler
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
@@ -17,6 +16,7 @@ import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.delay
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.ui.main.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
@@ -157,12 +157,17 @@ object NotificationManager {
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(): String {
val channelId = AppConfig.RAY_NG_CHANNEL_ID
val channelName = AppConfig.RAY_NG_CHANNEL_NAME
// Foreground-service notifications must remain visible; LOW is silent but valid.
val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
chan.lightColor = Color.DKGRAY
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
getNotificationManager()?.createNotificationChannel(chan)
val service = getService() ?: return channelId
NotificationHelper.ensureNotificationChannel(
context = service,
channelId = channelId,
channelNameRes = R.string.notification_channel_service,
// Foreground-service notifications must remain visible; LOW is silent but valid.
importance = NotificationManager.IMPORTANCE_LOW,
) {
lightColor = Color.DKGRAY
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
return channelId
}
@@ -176,7 +176,7 @@ object SettingsManager {
* @return True if bypassing LAN, false otherwise.
*/
fun routingRulesetsBypassLan(): Boolean {
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
if (vpnBypassLan == "1") {
return true
} else if (vpnBypassLan == "2") {
@@ -467,7 +467,7 @@ object SettingsManager {
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
@@ -16,7 +16,7 @@ import com.v2ray.ang.util.LogUtil
import java.io.Serializable
object MessageHelper {
const val EXTRA_REQUEST_ID = "requestId"
/**
* Sends a message to the service.
@@ -67,8 +67,8 @@ object MessageHelper {
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content)
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable, requestId: String? = null) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content, requestId)
}
/**
@@ -77,11 +77,12 @@ object MessageHelper {
* @param ctx The context.
* @param message The test service message containing key, subscriptionId, and serverGuids.
*/
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage, requestId: String? = null) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, CoreTestService::class.java)
intent.putExtra("content", message)
requestId?.let { intent.putExtra(EXTRA_REQUEST_ID, it) }
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -102,6 +103,9 @@ object MessageHelper {
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
if (message.key == AppConfig.MSG_MEASURE_CONFIG_START) {
sendMsg2UI(ctx, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
}
}
}
@@ -146,9 +150,11 @@ object MessageHelper {
* @param what The message identifier.
* @param content The message content.
*/
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable, requestId: String? = null) {
try {
ctx.sendBroadcast(messageIntent(action, what, content))
ctx.sendBroadcast(messageIntent(action, what, content).apply {
requestId?.let { putExtra(EXTRA_REQUEST_ID, it) }
})
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
}
@@ -6,9 +6,11 @@ import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Build
import androidx.annotation.StringRes
import androidx.core.app.NotificationCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.handler.AppLocaleManager
/**
* Unified notification helper for different notification channels.
@@ -124,20 +126,43 @@ object NotificationHelper {
return cachedNotificationManager!!
}
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) =
ensureNotificationChannel(
context = context,
channelId = channelType.channelId,
channelNameRes = channelType.channelNameRes,
importance = NotificationManager.IMPORTANCE_LOW,
)
/**
* Creates a channel or updates only its localized name.
*
* Android lets apps rename an existing channel, while its behavior remains under user control.
*/
internal fun ensureNotificationChannel(
context: Context,
channelId: String,
@StringRes channelNameRes: Int,
importance: Int,
configureNewChannel: NotificationChannel.() -> Unit = {},
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (notificationManager.getNotificationChannel(channelType.channelId) != null) return
val channel = NotificationChannel(
channelType.channelId,
channelType.channelName,
NotificationManager.IMPORTANCE_LOW
).apply {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val localizedName = AppLocaleManager.localizedContext(context).getString(channelNameRes)
val existingChannel = notificationManager.getNotificationChannel(channelId)
if (existingChannel == null) {
NotificationChannel(channelId, localizedName, importance)
.apply {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
configureNewChannel()
}
.also(notificationManager::createNotificationChannel)
} else if (existingChannel.name.toString() != localizedName) {
existingChannel.name = localizedName
notificationManager.createNotificationChannel(existingChannel)
}
notificationManager.createNotificationChannel(channel)
}
private fun buildNotificationBuilder(
@@ -6,7 +6,6 @@ import com.v2ray.ang.root.RootManager.refresh
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
/**
* Detects whether the device grants root (`su`) access.
@@ -43,21 +42,17 @@ object RootManager {
private fun probe(): Boolean {
return try {
val process = ProcessBuilder("su", "-c", "id -u")
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }.trim()
val finished = process.waitFor(10, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
val result = RootProcessRunner.run(listOf("su", "-c", "id -u"), 10000, 1024)
if (result.code == -1) {
LogUtil.w(AppConfig.TAG, "RootManager: su probe timed out")
return false
}
val isRoot = process.exitValue() == 0 && output.lineSequence().lastOrNull()?.trim() == "0"
val isRoot = result.code == 0 && result.output.lineSequence().filter { it.isNotBlank() }.lastOrNull()?.trim() == "0"
LogUtil.i(AppConfig.TAG, "RootManager: root available = $isRoot")
isRoot
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "RootManager: no root access (${e.message})")
if (e is InterruptedException) Thread.currentThread().interrupt()
LogUtil.w(AppConfig.TAG, "RootManager: root probe failed", e)
false
}
}
@@ -0,0 +1,41 @@
package com.v2ray.ang.root
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/** Owns one bounded root command. Neither inherited stdout nor output volume extends its deadline. */
internal object RootProcessRunner {
data class Outcome(val code: Int, val output: String)
fun run(command: List<String>, timeoutMillis: Long, outputLimit: Int = 65536): Outcome {
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val captured = ByteArrayOutputStream()
val buffer = ByteArray(4096)
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis)
var code = -1
try {
// Process.waitFor(timeout, unit) is API 26. Polling supports API 24–25 too.
// Read only available bytes: a detached helper may retain stdout after su exits.
while (System.nanoTime() < deadline) {
val available = process.inputStream.available()
if (available > 0) {
val size = process.inputStream.read(buffer, 0, minOf(buffer.size, available))
val remaining = outputLimit - captured.size()
if (size > 0 && remaining > 0) captured.write(buffer, 0, minOf(size, remaining))
}
try {
code = process.exitValue()
if (process.inputStream.available() == 0) break
} catch (_: IllegalThreadStateException) {
if (available == 0) Thread.sleep(10)
}
}
} finally {
if (code == -1) process.destroy()
process.inputStream.close()
process.outputStream.close()
process.errorStream.close()
}
return Outcome(code, captured.toString("UTF-8"))
}
}
@@ -4,7 +4,6 @@ import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Minimal root command runner backed by the `su` binary.
@@ -24,32 +23,29 @@ object RootShell {
val dir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
val file = File(dir, name).apply {
writeText(script)
setExecutable(true, false)
setReadable(false, false)
setReadable(true, true)
setWritable(false, false)
setWritable(true, true)
}
val safePath = file.absolutePath.replace("'", "'\\''")
return exec("sh '$safePath'")
// Android toybox timeout owns the root shell, so killing the su client cannot leave
// a timed-out setup shell installing rules after rollback has already started.
return exec("timeout -s TERM -k 2 25 sh '$safePath'")
}
private fun exec(command: String, timeoutSeconds: Long = 30): Result {
return try {
val process = ProcessBuilder("su", "-c", command)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
if (!finished) {
process.destroy()
LogUtil.e(AppConfig.TAG, "RootShell: timed out: $command")
return Result(-1, output)
}
val result = Result(process.exitValue(), output)
val outcome = RootProcessRunner.run(listOf("su", "-c", command), timeoutSeconds * 1000)
val result = Result(outcome.code, outcome.output)
if (!result.success) {
LogUtil.w(AppConfig.TAG, "RootShell: '$command' exited ${result.code}: ${output.trim()}")
LogUtil.w(AppConfig.TAG, "RootShell: root command exited ${result.code}: ${result.output.trim()}")
}
result
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "RootShell: failed to run '$command'", e)
Result(-1, e.message ?: e.javaClass.simpleName)
if (e is InterruptedException) Thread.currentThread().interrupt()
LogUtil.e(AppConfig.TAG, "RootShell: root command failed", e)
Result(-1, e.javaClass.simpleName)
}
}
}
@@ -10,6 +10,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
@@ -19,7 +20,7 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.util.LogUtil
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class CoreTestService : Service() {
@@ -28,7 +29,7 @@ class CoreTestService : Service() {
}
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val activeWorkers = ConcurrentHashMap<RealPingWorkerService, String>()
private val cancelAction by lazy {
val intent = Intent(this, CoreTestService::class.java).putExtra(
"content",
@@ -69,10 +70,7 @@ class CoreTestService : Service() {
*/
override fun onDestroy() {
LogUtil.i(AppConfig.TAG, "CoreTestService is being destroyed, cancelling ${activeWorkers.size} active workers")
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
cancelWorkers()
NotificationHelper.stopForeground(this)
super.onDestroy()
}
@@ -99,7 +97,9 @@ class CoreTestService : Service() {
}
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(message, startId)
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(
message, startId, intent.getStringExtra(MessageHelper.EXTRA_REQUEST_ID).orEmpty()
)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> handleMeasureCancel()
else -> {
NotificationHelper.stopForeground(this); stopSelf(startId)
@@ -108,7 +108,7 @@ class CoreTestService : Service() {
return START_NOT_STICKY
}
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
private fun handleMeasureStart(message: TestServiceMessage, startId: Int, requestId: String) {
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
val guidsList = when {
@@ -123,17 +123,22 @@ class CoreTestService : Service() {
context = this,
guids = guidsList,
onlyTcp = message.onlyTcp,
onEvent = { event -> handleWorkerEvent(event, message) { activeWorkers.remove(worker) } }
onEvent = { event ->
if (activeWorkers.containsKey(worker)) {
handleWorkerEvent(event, message, requestId) { activeWorkers.remove(worker) }
}
}
)
activeWorkers.add(worker)
activeWorkers[worker] = requestId
worker.start()
} else {
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
NotificationHelper.stopForeground(this)
stopSelf(startId)
}
}
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, onWorkerDone: () -> Unit) {
private fun handleWorkerEvent(event: RealPingEvent, message: TestServiceMessage, requestId: String, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
@@ -142,12 +147,17 @@ class CoreTestService : Service() {
title = getString(R.string.app_name),
content = getString(R.string.connection_running_task_left, event.text)
)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text, requestId)
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
MessageHelper.sendMsg2UI(
this,
AppConfig.MSG_MEASURE_CONFIG_SUCCESS,
RealPingResult(event.guid, event.delayMillis),
requestId,
)
}
is RealPingEvent.Finish -> {
@@ -161,7 +171,7 @@ class CoreTestService : Service() {
}
}
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status, requestId)
onWorkerDone()
if (activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this)
@@ -172,12 +182,19 @@ class CoreTestService : Service() {
}
private fun handleMeasureCancel() {
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, "0")
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
cancelWorkers()
NotificationHelper.stopForeground(this)
stopSelf()
}
private fun cancelWorkers() {
// Reply for each cancelled request, never for a newer UI request that has not started here.
activeWorkers.entries.toList().forEach { (worker, requestId) ->
if (activeWorkers.remove(worker, requestId)) {
worker.cancel()
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
}
}
}
}
@@ -8,18 +8,21 @@ import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import com.v2ray.ang.contracts.IDialerService
import java.util.concurrent.ExecutionException
import java.util.concurrent.FutureTask
import java.util.concurrent.atomic.AtomicLong
class DialerWebviewService : IDialerService {
private var webView: WebView? = null
private val handler = Handler(Looper.getMainLooper())
private val lifecycleVersion = AtomicLong()
private val keepAliveInterval = 30_000L // 30 seconds
private val keepAliveRunnable = object : Runnable {
override fun run() {
webView?.let {
it.resumeTimers()
it.onResume()
}
val view = webView ?: return
view.resumeTimers()
view.onResume()
handler.postDelayed(this, keepAliveInterval)
}
}
@@ -30,45 +33,88 @@ class DialerWebviewService : IDialerService {
* @param context Service context
*/
@SuppressLint("SetJavaScriptEnabled")
override fun start(context: Context, dialerAddr: String) {
if (webView != null) stop()
if (dialerAddr.isEmpty()) return
override fun start(context: Context, dialerAddr: String) = updateOnMainThread {
stopOnMainThread()
if (dialerAddr.isEmpty()) return@updateOnMainThread
val dialerUrl = "http://$dialerAddr/"
webView = WebView(context.applicationContext).apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
// Allow JS to run even if not triggered by user
mediaPlaybackRequiresUserGesture = false
// Prevent aggressive caching issues
cacheMode = WebSettings.LOAD_DEFAULT
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
view?.onResume()
view?.resumeTimers()
val view = WebView(context.applicationContext)
webView = view
try {
view.apply {
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
// Allow JS to run even if not triggered by user
mediaPlaybackRequiresUserGesture = false
// Prevent aggressive caching issues
cacheMode = WebSettings.LOAD_DEFAULT
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
if (view === webView) {
view?.onResume()
view?.resumeTimers()
}
}
}
loadUrl(dialerUrl)
}
loadUrl(dialerUrl)
handler.post(keepAliveRunnable)
} catch (e: Exception) {
try {
stopOnMainThread()
} catch (cleanupError: Exception) {
e.addSuppressed(cleanupError)
}
throw e
}
handler.post(keepAliveRunnable)
}
override fun stop() {
override fun stop() = updateOnMainThread { stopOnMainThread() }
private fun stopOnMainThread() {
handler.removeCallbacks(keepAliveRunnable)
webView?.apply {
stopLoading()
pauseTimers()
// Important to call onPause to stop internal Chromium threads properly
onPause()
destroy()
}
val view = webView ?: return
webView = null
view.apply {
try {
stopLoading()
pauseTimers()
// Important to call onPause to stop internal Chromium threads properly
onPause()
} finally {
destroy()
}
}
}
private fun updateOnMainThread(action: () -> Unit) {
val version = lifecycleVersion.incrementAndGet()
val update = {
// A stop or replacement can overtake a worker's queued start on the main thread.
if (lifecycleVersion.get() == version) action()
}
if (Looper.myLooper() == handler.looper) {
update()
return
}
// WebView requires its creating looper on all Android versions. Keep the dialer's
// synchronous contract so reload waits for cleanup/setup and receives any failure.
val task = FutureTask(update)
check(handler.post(task)) { "WebView looper is shutting down" }
try {
task.get()
} catch (e: ExecutionException) {
throw e.cause ?: e
} catch (e: InterruptedException) {
task.cancel(false)
Thread.currentThread().interrupt()
throw e
}
}
}
@@ -97,7 +97,7 @@ class TProxyService(
appendLine("misc:")
appendLine(" tcp-read-write-timeout: ${tcpTimeout * 1000}")
appendLine(" udp-read-write-timeout: ${udpTimeout * 1000}")
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) ?: "warn"}")
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)}")
}
}
@@ -96,6 +96,13 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
return localizedContext.getString(resId, *formatArgs)
}
/**
* Get a localized quantity string from a plurals resource.
*/
fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any): String {
return localizedContext.resources.getQuantityString(resId, quantity, *formatArgs)
}
/**
* Send finish activity event.
*/
@@ -32,7 +32,6 @@ class CheckUpdateViewModel(application: Application) : BaseViewModel(application
fun checkForUpdates() {
launchLoading {
toast(R.string.update_checking_for_update)
try {
val result = UpdateCheckerManager.checkForUpdate(_checkPreRelease.value)
if (result.hasUpdate) {
@@ -55,8 +55,8 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.v2ray.ang.R
import com.v2ray.ang.util.AppIconFetcher
import sh.calvin.reorderable.ReorderableCollectionItemScope
@@ -37,6 +37,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
@@ -47,6 +48,7 @@ fun ConfirmDialog(
confirmText: String = stringResource(R.string.action_ok),
dismissText: String? = stringResource(R.string.action_cancel),
confirmIcon: @Composable (() -> Unit)? = null,
messageTextAlign: TextAlign = TextAlign.Start,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
@@ -61,7 +63,14 @@ fun ConfirmDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = title?.let { { Text(it) } },
text = { Text(message, style = MaterialTheme.typography.bodyMedium) },
text = {
Text(
text = message,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
textAlign = messageTextAlign
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(); onDismiss() },
@@ -91,9 +100,11 @@ fun DeleteConfirmDialog(
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
itemName: String? = null,
) {
ConfirmDialog(
message = message,
message = if (itemName.isNullOrBlank()) message else "$message\n\n$itemName",
messageTextAlign = TextAlign.Center,
confirmText = stringResource(R.string.action_delete),
confirmIcon = {
Icon(
@@ -38,6 +38,8 @@ fun FormTextField(
keyboardType: KeyboardType = KeyboardType.Text,
placeholder: String? = null,
maxLines: Int = 5,
isError: Boolean = false,
supportingText: String? = null,
) {
Box(
modifier = modifier
@@ -49,6 +51,8 @@ fun FormTextField(
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
isError = isError,
supportingText = supportingText?.let { { Text(it) } },
singleLine = false,
maxLines = maxLines,
enabled = enabled,
@@ -111,12 +111,12 @@ val colorFabInactiveDark = Color(0xFF646464) // Dark Gray
val dividerColorLight = Color(0xFFE0E0E0) // Light Gray
val dividerColorDark = Color(0xFF424242) // Dark Gray
// Toast Colors 70%
val toastNormalBgLight = Color(0xB3353A3E) // Dark Gray
val toastNormalBgDark = Color(0xB34A4F54) // Darker Gray
val toastSuccessBg = Color(0xB3388E3C) // Green
val toastErrorBg = Color(0xB3D50000) // Red
val toastInfoBg = Color(0xB33F51B5) // Indigo Blue
// Toast Colors 85%
val toastNormalBgLight = Color(0xD9353A3E) // Dark Gray
val toastNormalBgDark = Color(0xD94A4F54) // Darker Gray
val toastSuccessBg = Color(0xD9388E3C) // Green
val toastErrorBg = Color(0xD9D50000) // Red
val toastInfoBg = Color(0xD93F51B5) // Indigo Blue
val toastIconCircleBg = Color(0x33FFFFFF) // Semi-transparent White
val toastTextColor = Color.White // White
@@ -12,9 +12,10 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
@@ -131,7 +132,7 @@ fun LogcatScreen(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val logs by viewModel.filteredLogs.collectAsStateWithLifecycle()
val logs by viewModel.logEntries.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
var searchQuery by remember { mutableStateOf("") }
@@ -191,9 +192,10 @@ fun LogcatScreen(
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
viewModel.loadLogcat()
}) {
FloatingActionButton(
onClick = { viewModel.loadLogcat() },
modifier = Modifier.navigationBarsPadding()
) {
Icon(
painterResource(R.drawable.ic_restore_24dp),
contentDescription = stringResource(R.string.acc_refresh)
@@ -213,8 +215,8 @@ fun LogcatScreen(
.verticalScrollbar(listState),
contentPadding = NavigationBarsBottomPadding()
) {
itemsIndexed(items = logs, key = { index, _ -> index }) { _, log ->
LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) })
items(items = logs, key = { it.key }) { log ->
LogcatItem(log = log.text, onLongClick = { Utils.setClipboard(context, log.text) })
ItemDivider()
}
}
@@ -1,14 +1,20 @@
package com.v2ray.ang.ui.logcat
import android.app.Application
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.io.IOException
class LogcatViewModel(application: Application) : BaseViewModel(application) {
@@ -17,6 +23,10 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
private val _filteredLogs = MutableStateFlow<List<String>>(emptyList())
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
val logEntries: StateFlow<List<LogcatEntry>> = filteredLogs
.map(::createLogcatEntries)
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun loadLogcat() {
launchLoading {
@@ -73,4 +83,17 @@ class LogcatViewModel(application: Application) : BaseViewModel(application) {
logsetsAll.filter { it.contains(currentFilter) }
}
}
}
}
data class LogcatEntry(val key: String, val text: String)
internal fun createLogcatEntries(logs: List<String>): List<LogcatEntry> {
val occurrences = HashMap<String, Int>()
// Identical lines are valid. Count oldest-first so filtering other lines or
// prepending newer entries does not change the keys of existing occurrences.
return logs.asReversed().map { line ->
val occurrence = occurrences.getOrDefault(line, 0)
occurrences[line] = occurrence + 1
LogcatEntry("$occurrence:$line", line)
}.asReversed()
}
@@ -186,8 +186,7 @@ class MainActivity : HelperBaseComponentActivity() {
toast(R.string.title_file_chooser)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN &&
MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
) {
checkAndRequestPermission(PermissionType.ACCESS_LOCAL_NETWORK) {}
}
@@ -1,5 +1,7 @@
package com.v2ray.ang.ui.main
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -20,9 +22,13 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -33,6 +39,7 @@ import com.v2ray.ang.ui.compose.AppDivider
import com.v2ray.ang.ui.compose.colorFabActive
import com.v2ray.ang.ui.compose.colorFabInactiveDark
import com.v2ray.ang.ui.compose.colorFabInactiveLight
import kotlinx.coroutines.launch
@Composable
fun MainBottomBar(
@@ -41,6 +48,15 @@ fun MainBottomBar(
isDarkTheme: Boolean,
onAction: (MainAction) -> Unit
) {
val scope = rememberCoroutineScope()
val rotationAnim = remember { Animatable(0f) }
LaunchedEffect(isRunning) {
if (!isRunning) {
rotationAnim.snapTo(0f)
}
}
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
@@ -68,7 +84,17 @@ fun MainBottomBar(
}
}
FloatingActionButton(
onClick = { onAction(MainAction.ToggleService) },
onClick = {
if (!isRunning) {
scope.launch {
rotationAnim.animateTo(
targetValue = 720f,
animationSpec = tween(durationMillis = 3000)
)
}
}
onAction(MainAction.ToggleService)
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(end = 24.dp)
@@ -85,7 +111,9 @@ fun MainBottomBar(
if (isRunning) R.string.acc_stop else R.string.acc_start
),
tint = Color.White,
modifier = Modifier.size(24.dp)
modifier = Modifier
.size(24.dp)
.graphicsLayer { rotationZ = rotationAnim.value }
)
}
}
@@ -57,9 +57,9 @@ interface MainDataSource : Closeable {
fun share2Clipboard(guid: String): Boolean
fun sendMsg2Service(msgId: Int, content: String)
fun sendMsg2TestService(msg: TestServiceMessage)
fun sendMsg2TestService(msg: TestServiceMessage, requestId: String? = null)
fun cancelAllPing()
fun testCurrentServerRealPing()
fun testCurrentServerRealPing(requestId: String)
fun syncSubscriptions()
fun initAssets()
@@ -1,10 +1,23 @@
package com.v2ray.ang.ui.main
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
data class ServerDeleteTarget(
val guid: String,
val profileName: String,
) {
companion object {
val Saver = listSaver<ServerDeleteTarget?, String>(
save = { target -> target?.let { listOf(it.guid, it.profileName) }.orEmpty() },
restore = { ServerDeleteTarget(it[0], it[1]) },
)
}
}
@Composable
fun MainDialogs(
showDelAllConfirm: Boolean,
@@ -16,7 +29,7 @@ fun MainDialogs(
showDelInvalidConfirm: Boolean,
onDismissDelInvalid: () -> Unit,
onConfirmDelInvalid: () -> Unit,
showRemoveConfirm: String?,
showRemoveConfirm: ServerDeleteTarget?,
onDismissRemove: () -> Unit,
onConfirmRemove: (String) -> Unit,
) {
@@ -41,11 +54,11 @@ fun MainDialogs(
onDismiss = onDismissDelInvalid
)
}
if (showRemoveConfirm != null) {
val guid = showRemoveConfirm
showRemoveConfirm?.let { target ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
onConfirm = { onConfirmRemove(guid) },
itemName = target.profileName,
onConfirm = { onConfirmRemove(target.guid) },
onDismiss = onDismissRemove
)
}
@@ -1,28 +1,21 @@
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.ScrollableTabRow
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.material3.TabRowDefaults.tabIndicatorOffset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.entities.ServersCache
@@ -37,26 +30,20 @@ fun GroupTabBar(
modifier: Modifier = Modifier
) {
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,
ScrollableTabRow(
selectedTabIndex = selectedIndex,
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
contentPadding = PaddingValues(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
edgePadding = 16.dp,
indicator = { tabPositions ->
TabRowDefaults.SecondaryIndicator(
modifier = Modifier.tabIndicatorOffset(tabPositions[selectedIndex]),
color = MaterialTheme.colorScheme.secondary
)
}
) {
itemsIndexed(
items = groups,
key = { _, group -> group.id },
) { index, group ->
groups.forEachIndexed { index, group ->
val serverFlow = remember(group.id, mainViewModel) {
mainViewModel.serversForGroup(group.id)
}
@@ -84,29 +71,19 @@ private fun GroupTabItem(
"${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
)
}
)
if (selected) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(3.dp))
.background(MaterialTheme.colorScheme.secondary)
Tab(
selected = selected,
onClick = onClick,
modifier = Modifier
.widthIn(min = 56.dp)
.heightIn(min = 48.dp),
text = {
Text(
text = text,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis
)
}
}
)
}
@@ -79,7 +79,7 @@ fun ShareMethodDialog(
more: Boolean,
onDismiss: () -> Unit,
onAction: (MainAction) -> Unit,
onRemove: (String) -> Unit,
onRemove: (String, String) -> Unit,
) {
val menuActions = serverMenuActions(
isComplexProfile = profile.configType.isComplexType(),
@@ -95,7 +95,7 @@ fun ShareMethodDialog(
ServerMenuAction.ShareClipboard -> onAction(MainAction.ShareClipboard(guid))
ServerMenuAction.ShareFullContent -> onAction(MainAction.ShareFullContent(guid))
ServerMenuAction.Edit -> onAction(MainAction.EditServer(guid, profile))
ServerMenuAction.Delete -> onRemove(guid)
ServerMenuAction.Delete -> onRemove(guid, profile.remarks)
}
},
onDismiss = onDismiss
@@ -9,6 +9,7 @@ import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.entities.ProfileItem
@@ -24,10 +25,9 @@ import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import java.util.concurrent.atomic.AtomicBoolean
class MainRepository(
@@ -39,40 +39,44 @@ class MainRepository(
private val closed = AtomicBoolean(false)
private val _mainServiceEvent = MutableSharedFlow<MainServiceEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
// Probe results are finite and must remain lossless until the ViewModel coalesces them.
private val mainServiceEventChannel = Channel<MainServiceEvent>(Channel.UNLIMITED)
override val mainServiceEvent: SharedFlow<MainServiceEvent> = _mainServiceEvent.asSharedFlow()
override val mainServiceEvent: Flow<MainServiceEvent> = mainServiceEventChannel.receiveAsFlow()
private val serviceReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val safeIntent = intent ?: return
val requestId = safeIntent.getStringExtra(MessageHelper.EXTRA_REQUEST_ID).orEmpty()
val event = when (safeIntent.getIntExtra("key", 0)) {
AppConfig.MSG_STATE_RUNNING -> MainServiceEvent.StateRunning
AppConfig.MSG_STATE_NOT_RUNNING -> MainServiceEvent.StateNotRunning
AppConfig.MSG_STATE_START_SUCCESS -> MainServiceEvent.StateStartSuccess
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure
AppConfig.MSG_STATE_START_FAILURE -> MainServiceEvent.StateStartFailure(
safeIntent.getStringExtra("content")
)
AppConfig.MSG_STATE_STOP_SUCCESS -> MainServiceEvent.StateStopSuccess
AppConfig.MSG_MEASURE_DELAY_RESULT -> safeIntent
.serializable<ConnectionTestResult>("content")
?.let { MainServiceEvent.MeasureDelayResult(it) }
?.let { MainServiceEvent.MeasureDelayResult(it, requestId) }
AppConfig.MSG_MEASURE_DELAY_CANCEL -> MainServiceEvent.MeasureDelayCancelled(requestId)
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> MainServiceEvent.MeasureConfigSuccess
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> safeIntent
.serializable<RealPingResult>("content")
?.let { MainServiceEvent.MeasureConfigSuccess(it, requestId) }
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> MainServiceEvent.MeasureConfigNotify(
safeIntent.getStringExtra("content").orEmpty()
safeIntent.getStringExtra("content").orEmpty(), requestId
)
AppConfig.MSG_MEASURE_CONFIG_FINISH -> MainServiceEvent.MeasureConfigFinish(
safeIntent.getStringExtra("content")
requestId
)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> MainServiceEvent.MeasureConfigCancelled(requestId)
else -> null
}
event?.let { _mainServiceEvent.tryEmit(it) }
event?.let { mainServiceEventChannel.trySend(it) }
}
}
@@ -98,6 +102,7 @@ class MainRepository(
}.onFailure {
LogUtil.e(AppConfig.TAG, "Failed to unregister main service receiver", it)
}
mainServiceEventChannel.close()
}
override fun getSelectedSubscriptionId(): String =
@@ -205,8 +210,8 @@ class MainRepository(
override fun sendMsg2Service(msgId: Int, content: String) =
MessageHelper.sendMsg2Service(app, msgId, content)
override fun sendMsg2TestService(msg: TestServiceMessage) =
MessageHelper.sendMsg2TestService(app, msg)
override fun sendMsg2TestService(msg: TestServiceMessage, requestId: String?) =
MessageHelper.sendMsg2TestService(app, msg, requestId)
override fun cancelAllPing() {
sendMsg2TestService(
@@ -214,8 +219,10 @@ class MainRepository(
)
}
override fun testCurrentServerRealPing() {
sendMsg2Service(AppConfig.MSG_MEASURE_DELAY, "")
override fun testCurrentServerRealPing(requestId: String) {
MessageHelper.sendMsg2ServiceForResult(app, AppConfig.MSG_MEASURE_DELAY, requestId) { handled ->
if (!handled) mainServiceEventChannel.trySend(MainServiceEvent.MeasureDelayCancelled(requestId))
}
}
override fun syncSubscriptions() {
@@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
@@ -57,11 +58,17 @@ fun MainScreen(
var showDelAllConfirm by remember { mutableStateOf(false) }
var showDelDuplicateConfirm by remember { mutableStateOf(false) }
var showDelInvalidConfirm by remember { mutableStateOf(false) }
var showRemoveConfirm by remember { mutableStateOf<String?>(null) }
var showRemoveConfirm by rememberSaveable(stateSaver = ServerDeleteTarget.Saver) {
mutableStateOf<ServerDeleteTarget?>(null)
}
var shareTarget by remember { mutableStateOf<Triple<String, ProfileItem, Boolean>?>(null) }
val removeServer: (String) -> Unit = { guid ->
if (confirmRemove) showRemoveConfirm = guid else onAction(MainAction.RemoveServer(guid))
val removeServer: (String, String) -> Unit = { guid, profileName ->
if (confirmRemove) {
showRemoveConfirm = ServerDeleteTarget(guid, profileName)
} else {
onAction(MainAction.RemoveServer(guid))
}
}
val pagerState = rememberPagerState(
@@ -75,7 +75,7 @@ fun GroupPagerPage(
onEditServer: (String, ProfileItem) -> Unit,
onShareServer: (String, ProfileItem) -> Unit,
onMoreServer: (String, ProfileItem) -> Unit,
onRemoveServer: (String) -> Unit,
onRemoveServer: (String, String) -> Unit,
contentPadding: PaddingValues
) {
val groupStateFlow = remember(groupId) {
@@ -121,7 +121,7 @@ private class ServerRowActions(
val edit: (String, ProfileItem) -> Unit,
val share: (String, ProfileItem) -> Unit,
val more: (String, ProfileItem) -> Unit,
val remove: (String) -> Unit,
val remove: (String, String) -> Unit,
)
@Composable
@@ -374,7 +374,7 @@ private fun ServerListItem(
Modifier.size(24.dp)
)
}
IconButton(onClick = { actions.remove(row.guid) }, Modifier.size(36.dp)) {
IconButton(onClick = { actions.remove(row.guid, row.remarks) }, Modifier.size(36.dp)) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
stringResource(R.string.acc_delete),
@@ -1,15 +1,18 @@
package com.v2ray.ang.ui.main
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.RealPingResult
sealed class MainServiceEvent {
data object StateRunning : MainServiceEvent()
data object StateNotRunning : MainServiceEvent()
data object StateStartSuccess : MainServiceEvent()
data object StateStartFailure : MainServiceEvent()
data class StateStartFailure(val message: String? = null) : MainServiceEvent()
data object StateStopSuccess : MainServiceEvent()
data class MeasureDelayResult(val result: ConnectionTestResult) : MainServiceEvent()
data object MeasureConfigSuccess : MainServiceEvent()
data class MeasureConfigNotify(val progress: String) : MainServiceEvent()
data class MeasureConfigFinish(val finishedCount: String?) : MainServiceEvent()
data class MeasureDelayResult(val result: ConnectionTestResult, val requestId: String) : MainServiceEvent()
data class MeasureDelayCancelled(val requestId: String) : MainServiceEvent()
data class MeasureConfigSuccess(val result: RealPingResult, val requestId: String) : MainServiceEvent()
data class MeasureConfigNotify(val progress: String, val requestId: String) : MainServiceEvent()
data class MeasureConfigFinish(val requestId: String) : MainServiceEvent()
data class MeasureConfigCancelled(val requestId: String) : MainServiceEvent()
}
@@ -0,0 +1,37 @@
package com.v2ray.ang.ui.main
import java.util.UUID
/** Main-thread request ownership; a late reply must not complete a newer test. */
internal class MainTestRequests {
data class Bulk(val id: String, val groupId: String)
private var current: String? = null
var bulk: Bulk? = null
private set
val isTesting: Boolean get() = current != null || bulk != null
fun beginCurrent(): String = UUID.randomUUID().toString().also { current = it }
fun completeCurrent(id: String): Boolean {
if (id != current) return false
current = null
return true
}
fun invalidateCurrent() {
current = null
}
fun beginBulk(groupId: String): Bulk = Bulk(UUID.randomUUID().toString(), groupId).also { bulk = it }
fun completeBulk(id: String): Bulk? {
val request = bulk?.takeIf { it.id == id } ?: return null
bulk = null
return request
}
fun cancelBulk() {
bulk = null
}
}
@@ -9,6 +9,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.ConnectionTestResult
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.LocateTarget
import com.v2ray.ang.dto.RealPingResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache
@@ -24,6 +25,7 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
@@ -40,6 +42,30 @@ import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.PatternSyntaxException
private fun applyTestDelayResults(
servers: List<ServersCache>,
updates: Map<String, Long>,
): List<ServersCache> = servers.map { server ->
val delayMillis = updates[server.guid]
if (delayMillis == null || delayMillis == server.testDelayMillis) {
server
} else {
server.copy(testDelayMillis = delayMillis)
}
}
private fun applyTestDelayResultsToRows(
rows: List<ServerRowUiModel>,
updates: Map<String, Long>,
): List<ServerRowUiModel> = rows.map { row ->
val delayMillis = updates[row.guid]
if (delayMillis == null || delayMillis == row.testDelayMillis) {
row
} else {
row.copy(testDelayMillis = delayMillis)
}
}
class MainViewModel(
application: Application,
private val dataSource: MainDataSource
@@ -77,9 +103,11 @@ class MainViewModel(
private var preloadJob: Job? = null
private var selectedGroupLoadJob: Job? = null
private var reloadJob: Job? = null
private var testResultFlushJob: Job? = null
private val pendingTestResults = linkedMapOf<String, Long>()
@Volatile
private var testingGroupId: String? = null
private val testRequests = MainTestRequests()
private var bulkTestJob: Job? = null
private val initialPageReady = CompletableDeferred<Unit>()
@@ -106,31 +134,87 @@ class MainViewModel(
updateRunningState(true)
}
MainServiceEvent.StateStartFailure -> {
toastError(R.string.toast_services_failure)
is MainServiceEvent.StateStartFailure -> {
if (!event.message.isNullOrBlank()) {
toastError(event.message)
} else {
toastError(R.string.toast_services_failure)
}
updateRunningState(false)
}
MainServiceEvent.StateStopSuccess -> updateRunningState(false)
is MainServiceEvent.MeasureDelayResult -> {
_uiState.update { it.copy(status = MainStatus.ConnectionTest(event.result)) }
if (!uiState.value.isRunning || !testRequests.completeCurrent(event.requestId)) return
_uiState.update { it.copy(isTesting = testRequests.isTesting, status = MainStatus.ConnectionTest(event.result)) }
}
MainServiceEvent.MeasureConfigSuccess -> {
viewModelScope.launch(ioDispatcher) {
val gid = testingGroupId ?: uiState.value.selectedGroupId
cacheMutex.withLock { groupDataCache.remove(gid) }
updateGroupUi(gid, loadGroup(gid, forceRefresh = true))
}
is MainServiceEvent.MeasureConfigSuccess -> {
val request = testRequests.bulk?.takeIf { it.id == event.requestId } ?: return
queueTestResult(event.result, request)
}
is MainServiceEvent.MeasureConfigNotify -> {
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
if (event.requestId == testRequests.bulk?.id) {
_uiState.update { it.copy(status = MainStatus.TestProgress(event.progress)) }
}
}
is MainServiceEvent.MeasureConfigFinish -> {
onTestsFinished()
val request = testRequests.bulk?.takeIf { it.id == event.requestId } ?: return
val scheduledFlush = testResultFlushJob
testResultFlushJob = viewModelScope.launch {
scheduledFlush?.cancelAndJoin()
if (testRequests.bulk?.id != request.id) return@launch
flushPendingTestResults(request)
onTestsFinished(request.id)
}
}
is MainServiceEvent.MeasureDelayCancelled -> {
if (testRequests.completeCurrent(event.requestId)) resetTestStatus()
}
is MainServiceEvent.MeasureConfigCancelled -> {
if (testRequests.completeBulk(event.requestId) != null) {
cancelPendingTestResults()
resetTestStatus()
}
}
}
}
private fun queueTestResult(result: RealPingResult, request: MainTestRequests.Bulk) {
pendingTestResults[result.guid] = result.delayMillis
if (testResultFlushJob?.isActive == true) return
testResultFlushJob = viewModelScope.launch {
while (pendingTestResults.isNotEmpty()) {
delay(TEST_RESULT_FLUSH_INTERVAL_MS)
flushPendingTestResults(request)
}
}
}
private suspend fun flushPendingTestResults(request: MainTestRequests.Bulk) {
if (pendingTestResults.isEmpty()) return
if (testRequests.bulk?.id != request.id) return
val updates = cacheMutex.withLock {
val drained = pendingTestResults.toMap()
pendingTestResults.clear()
groupDataCache[request.groupId]?.let { cached ->
groupDataCache[request.groupId] = applyTestDelayResults(cached, drained)
}
drained
}
if (updates.isEmpty()) return
if (testRequests.bulk?.id != request.id) return
mutableServerGroupState(request.groupId).update { current ->
current.copy(
servers = applyTestDelayResults(current.servers, updates),
rows = applyTestDelayResultsToRows(current.rows, updates),
)
}
}
@@ -464,7 +548,13 @@ class MainViewModel(
toast(R.string.title_update_subscription_no_subscription)
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
toast(dataSource.getString(R.string.title_update_config_count, result.configCount))
toast(
getQuantityString(
R.plurals.title_update_config_count,
result.configCount,
result.configCount,
)
)
else ->
toast(dataSource.getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
@@ -718,22 +808,30 @@ class MainViewModel(
// ---------- Testing ----------
fun cancelAllPing() {
bulkTestJob?.cancel()
bulkTestJob = null
testRequests.cancelBulk()
testRequests.invalidateCurrent()
cancelPendingTestResults()
resetTestStatus()
dataSource.cancelAllPing()
testingGroupId = null
}
private fun resetTestStatus() {
_uiState.update {
it.copy(
isTesting = false,
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
isTesting = testRequests.isTesting,
status = if (testRequests.isTesting) MainStatus.Testing
else if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
)
}
}
fun testAllRealPing(onlyTcp: Boolean = false) {
dataSource.cancelAllPing()
cancelAllPing()
val groupId = uiState.value.selectedGroupId
val servers = currentServers()
if (servers.isEmpty()) {
_uiState.update { it.copy(isTesting = false) }
return
}
val serverGuids = servers.map { it.guid }
@@ -749,42 +847,54 @@ class MainViewModel(
}
)
}
testingGroupId = groupId
val request = testRequests.beginBulk(groupId)
val message = TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = groupId,
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
onlyTcp = onlyTcp
)
_uiState.update {
it.copy(
isTesting = true,
status = MainStatus.Testing
)
}
viewModelScope.launch(ioDispatcher) {
dataSource.clearAllTestDelayResults(serverGuids)
cacheMutex.withLock { groupDataCache.remove(groupId) }
dataSource.sendMsg2TestService(
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = groupId,
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
onlyTcp = onlyTcp
)
)
bulkTestJob = viewModelScope.launch {
withContext(ioDispatcher) {
dataSource.clearAllTestDelayResults(serverGuids)
val resetGuids = serverGuids.toHashSet()
cacheMutex.withLock {
groupDataCache[groupId]?.let { cached ->
groupDataCache[groupId] = cached.map { server ->
if (server.guid !in resetGuids || server.testDelayMillis == 0L) server
else server.copy(testDelayMillis = 0L)
}
}
}
}
dataSource.sendMsg2TestService(message, request.id)
}
}
fun testCurrentServerRealPing() {
_uiState.update { it.copy(status = MainStatus.Testing) }
dataSource.testCurrentServerRealPing()
private fun cancelPendingTestResults() {
testResultFlushJob?.cancel()
testResultFlushJob = null
pendingTestResults.clear()
}
private fun onTestsFinished() {
fun testCurrentServerRealPing() {
if (!uiState.value.isRunning) return
val requestId = testRequests.beginCurrent()
_uiState.update { it.copy(isTesting = true, status = MainStatus.Testing) }
dataSource.testCurrentServerRealPing(requestId)
}
private fun onTestsFinished(requestId: String) {
if (testRequests.completeBulk(requestId) == null) return
resetTestStatus()
viewModelScope.launch(ioDispatcher) {
cacheMutex.withLock { groupDataCache.clear() }
testingGroupId = null
_uiState.update {
it.copy(
isTesting = false,
status = if (it.isRunning) MainStatus.Connected else MainStatus.Disconnected
)
}
reloadAllGroups(_uiState.value.groups.map { it.id })
}
}
@@ -812,10 +922,12 @@ class MainViewModel(
// ---------- Running state ----------
private fun updateRunningState(running: Boolean, clearTestingText: Boolean = true) {
if (!running || clearTestingText) testRequests.invalidateCurrent()
_uiState.update { state ->
state.copy(
isRunning = running,
status = if (!clearTestingText && state.isTesting) state.status
isTesting = testRequests.isTesting,
status = if (!clearTestingText && state.isRunning == running) state.status
else if (running) MainStatus.Connected else MainStatus.Disconnected
)
}
@@ -842,4 +954,8 @@ class MainViewModel(
throw IllegalArgumentException("Unknown ViewModel class")
}
}
private companion object {
const val TEST_RESULT_FLUSH_INTERVAL_MS = 500L
}
}
@@ -41,13 +41,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.extension.toastInfo
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppDivider
import com.v2ray.ang.ui.compose.AppDropdownMenuItems
import com.v2ray.ang.ui.compose.AppListItem
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.ConfirmDialog
import com.v2ray.ang.ui.compose.ItemDivider
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.verticalScrollbar
@@ -87,9 +87,6 @@ class PerAppProxyActivity : BaseComponentActivity() {
onBackClick = { finish() },
onPerAppProxyChanged = { viewModel.setPerAppProxyEnabled(it) },
onBypassAppsChanged = { viewModel.setBypassAppsEnabled(it) },
onInfoClick = {
toastInfo(R.string.summary_pref_per_app_proxy)
},
onToggleApp = { viewModel.toggle(it) },
onSearch = { viewModel.filterApps(it) },
onSelectAll = { viewModel.selectAll() },
@@ -118,7 +115,6 @@ fun PerAppProxyScreen(
onBackClick: () -> Unit,
onPerAppProxyChanged: (Boolean) -> Unit,
onBypassAppsChanged: (Boolean) -> Unit,
onInfoClick: () -> Unit,
onToggleApp: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit,
@@ -130,6 +126,8 @@ fun PerAppProxyScreen(
var showSearch by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
var showInfoDialog by rememberSaveable { mutableStateOf(false) }
val onInfoClick = { showInfoDialog = true }
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
@@ -281,4 +279,13 @@ fun PerAppProxyScreen(
}
}
}
if (showInfoDialog) {
ConfirmDialog(
message = stringResource(R.string.summary_pref_per_app_proxy),
dismissText = null,
onConfirm = {},
onDismiss = { showInfoDialog = false },
)
}
}
@@ -37,7 +37,6 @@ import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.apppicker.AppPickerActivity
@@ -86,7 +85,6 @@ class RoutingEditActivity : BaseComponentActivity() {
private fun saveServer(rulesetItem: RulesetItem): Boolean {
if (rulesetItem.remarks.isNullOrEmpty()) {
toast(R.string.sub_setting_remarks)
return false
}
if (position < 0 && rulesetItem.id.isEmpty()) {
@@ -124,6 +122,7 @@ fun RoutingEditScreen(
val scrollState = rememberScrollState()
var remarks by rememberSaveable { mutableStateOf(initial?.remarks ?: "") }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var locked by rememberSaveable { mutableStateOf(initial?.locked == true) }
var domain by rememberSaveable { mutableStateOf(initial?.domain?.joinToString(",") ?: "") }
var ip by rememberSaveable { mutableStateOf(initial?.ip?.joinToString(",") ?: "") }
@@ -193,7 +192,15 @@ fun RoutingEditScreen(
)
}
}
IconButton(onClick = { onSave(buildRuleset()) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(buildRuleset())
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -216,7 +223,8 @@ fun RoutingEditScreen(
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
SettingsSwitchItem(
title = stringResource(R.string.routing_settings_locked),
@@ -309,6 +317,7 @@ fun RoutingEditScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_routing_rule),
itemName = initial?.remarks.orEmpty(),
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -105,20 +105,23 @@ abstract class BaseServerActivity : BaseComponentActivity() {
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FormTextField(
stringResource(R.string.server_lab_remarks),
state.remarks,
{ state.remarks = it }
label = stringResource(R.string.server_lab_remarks),
value = state.remarks,
onValueChange = { state.remarks = it },
isError = state.isRemarksError
)
FormTextField(
stringResource(R.string.server_lab_address),
state.address,
{ state.address = it }
label = stringResource(R.string.server_lab_address),
value = state.address,
onValueChange = { state.address = it },
isError = state.isAddressError
)
FormTextField(
stringResource(R.string.server_lab_port),
state.port,
{ state.port = it },
keyboardType = KeyboardType.Number
label = stringResource(R.string.server_lab_port),
value = state.port,
onValueChange = { state.port = it },
keyboardType = KeyboardType.Number,
isError = state.isPortError
)
}
}
@@ -365,33 +368,27 @@ abstract class BaseServerActivity : BaseComponentActivity() {
}
protected fun validateBasicConfig(state: ServerUiState): Boolean {
if (state.remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
if (state.address.isBlank()) {
toast(R.string.server_lab_address)
return false
}
if (
state.configType != EConfigType.HYSTERIA2 &&
(state.port.toIntOrNull() ?: 0) <= 0
) {
toast(R.string.server_lab_port)
return false
}
return true
val remarksErr = state.remarks.isBlank()
val addressErr = state.address.isBlank()
val portErr = state.configType != EConfigType.HYSTERIA2 && (state.port.toIntOrNull() ?: 0) <= 0
state.isRemarksError = remarksErr
state.isAddressError = addressErr
state.isPortError = portErr
val hasError = remarksErr || addressErr || portErr
return !hasError
}
protected open fun validateProtocolConfig(config: ProfileItem): Boolean = true
protected open fun validateCommonConfig(config: ProfileItem): Boolean {
protected open fun validateCommonConfig(state: ServerUiState, config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
state.isPasswordError = true
if (config.configType == EConfigType.VMESS ||
config.configType == EConfigType.VLESS
) {
toast(R.string.server_lab_id)
return false
}
@@ -399,7 +396,6 @@ abstract class BaseServerActivity : BaseComponentActivity() {
config.configType == EConfigType.SHADOWSOCKS ||
config.configType == EConfigType.HYSTERIA2
) {
toast(R.string.server_lab_id3)
return false
}
}
@@ -425,7 +421,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
protected fun saveServer(state: ServerUiState): Boolean {
if (!validateBasicConfig(state)) return false
val config = state.toProfileItem(initialConfig)
if (!validateCommonConfig(config)) return false
if (!validateCommonConfig(state, config)) return false
if (!validateProtocolConfig(config)) return false
config.description = AngConfigManager.generateDescription(config)
@@ -491,6 +487,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
if (showDeleteDialog) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialConfig.remarks,
onConfirm = {
showDeleteDialog = false
deleteServer(editGuid)
@@ -0,0 +1,14 @@
package com.v2ray.ang.ui.server
/** Removes one draft member and its row key together, resolving its current position at confirmation. */
internal fun withoutProxyChainMember(
members: List<String>,
memberKeys: List<String>,
memberKey: String,
): Pair<List<String>, List<String>> {
val index = memberKeys.indexOf(memberKey)
if (index < 0) return members to memberKeys
return members.toMutableList().also { it.removeAt(index) } to
memberKeys.toMutableList().also { it.removeAt(index) }
}
@@ -1,526 +0,0 @@
package com.v2ray.ang.ui.server
import android.os.Bundle
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.v2ray.ang.AppConfig.DEFAULT_PORT
import com.v2ray.ang.AppConfig.REALITY
import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.CertificateFingerprintManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppTopBar
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.JsonUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ServerActivity : BaseComponentActivity() {
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
private val isRunning by lazy {
intent.getBooleanExtra("isRunning", false)
&& editGuid.isNotEmpty()
&& editGuid == MmkvManager.getSelectServer()
}
private val createConfigType by lazy {
EConfigType.fromInt(intent.getIntExtra("createConfigType", EConfigType.VMESS.value))
?: EConfigType.VMESS
}
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
private lateinit var initialConfig: ProfileItem
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val existingConfig = MmkvManager.decodeServerConfig(editGuid)
initialConfig = existingConfig ?: ProfileItem.create(createConfigType)
}
@Composable
override fun ScreenContent() {
ServerScreen(
guid = editGuid,
configType = initialConfig.configType,
initialConfig = initialConfig,
isRunning = isRunning,
onBackClick = { finish() },
onSave = { saveServer(it) },
onDelete = { deleteServer(editGuid, isRunning) }
)
}
private fun saveServer(config: ProfileItem): Boolean {
if (config.remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
if (config.server.isNullOrBlank()) {
toast(R.string.server_lab_address)
return false
}
if (
config.configType != EConfigType.HYSTERIA2 &&
(config.serverPort?.toIntOrNull() ?: 0) <= 0
) {
toast(R.string.server_lab_port)
return false
}
if (
config.configType != EConfigType.SOCKS &&
config.configType != EConfigType.HTTP &&
config.password.isNullOrBlank()
) {
val message = when (config.configType) {
EConfigType.TROJAN,
EConfigType.SHADOWSOCKS,
EConfigType.HYSTERIA2 -> R.string.server_lab_id3
else -> R.string.server_lab_id
}
toast(message)
return false
}
if (
config.configType == EConfigType.TROJAN &&
config.security.isNullOrBlank()
) {
toast(R.string.server_lab_stream_security)
return false
}
if (
!config.xhttpExtra.isNullOrBlank() &&
JsonUtil.parseString(config.xhttpExtra) == null
) {
toast(R.string.server_lab_xhttp_extra)
return false
}
if (
!config.finalMask.isNullOrBlank() &&
JsonUtil.parseString(config.finalMask) == null
) {
toast(R.string.server_lab_final_mask)
return false
}
config.description =
AngConfigManager.generateDescription(config)
if (
config.subscriptionId.isEmpty() &&
!subscriptionId.isNullOrEmpty()
) {
config.subscriptionId = subscriptionId.orEmpty()
}
val savedGuid = MmkvManager.encodeServerConfig(
editGuid,
config
)
toastSuccess(R.string.toast_success)
ProfileEditorResult.run {
finishSaved(
guid = savedGuid,
restartService = isRunning
)
}
return true
}
private fun deleteServer(
guid: String,
isRunning: Boolean
) {
if (
guid.isEmpty() ||
guid == MmkvManager.getSelectServer()
) {
toast(R.string.toast_action_not_allowed)
return
}
MmkvManager.removeServer(guid)
ProfileEditorResult.run {
finishDeleted(guid)
}
}
}
@Composable
fun ServerScreen(
guid: String,
configType: EConfigType,
initialConfig: ProfileItem,
isRunning: Boolean,
onBackClick: () -> Unit,
onSave: (ProfileItem) -> Boolean,
onDelete: () -> Unit
) {
val context = LocalContext.current
val securityOptions = stringArrayResource(R.array.securitys).toList()
val ssSecurityOptions = stringArrayResource(R.array.ss_securitys).toList()
val flowOptions = stringArrayResource(R.array.flows).toList()
val networkOptions = stringArrayResource(R.array.networks).toList()
val tcpHeaderOptions = stringArrayResource(R.array.header_type_tcp).toList()
val kcpHeaderOptions = stringArrayResource(R.array.header_type_kcp_and_quic).toList()
val grpcModeOptions = stringArrayResource(R.array.mode_type_grpc).toList()
val xhttpModeOptions = stringArrayResource(R.array.xhttp_mode).toList()
val streamSecurityOptions = stringArrayResource(R.array.streamsecurityxs).toList()
val uTlsOptions = stringArrayResource(R.array.streamsecurity_utls).toList()
val alpnOptions = stringArrayResource(R.array.streamsecurity_alpn).toList()
val browserDialerOptions = stringArrayResource(R.array.browser_dialer_mode_value).toList()
var remarks by rememberSaveable { mutableStateOf(initialConfig.remarks) }
var address by rememberSaveable { mutableStateOf(initialConfig.server ?: "") }
var port by rememberSaveable { mutableStateOf(initialConfig.serverPort ?: DEFAULT_PORT.toString()) }
var password by rememberSaveable { mutableStateOf(initialConfig.password ?: "") }
var method by rememberSaveable { mutableStateOf(initialConfig.method ?: "") }
var flow by rememberSaveable { mutableStateOf(initialConfig.flow ?: "") }
var encryption by rememberSaveable { mutableStateOf(initialConfig.method ?: "") }
var username by rememberSaveable { mutableStateOf(initialConfig.username ?: "") }
var secretKey by rememberSaveable { mutableStateOf(initialConfig.secretKey ?: "") }
var publicKey by rememberSaveable { mutableStateOf(initialConfig.publicKey ?: "") }
var preSharedKey by rememberSaveable { mutableStateOf(initialConfig.preSharedKey ?: "") }
var reserved by rememberSaveable { mutableStateOf(initialConfig.reserved ?: "0,0,0") }
var localAddress by rememberSaveable { mutableStateOf(initialConfig.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4) }
var mtu by rememberSaveable { mutableStateOf(initialConfig.mtu?.toString() ?: WIREGUARD_LOCAL_MTU) }
var obfsPassword by rememberSaveable { mutableStateOf(initialConfig.obfsPassword ?: "") }
var portHopping by rememberSaveable { mutableStateOf(initialConfig.portHopping ?: "") }
var portHoppingInterval by rememberSaveable { mutableStateOf(initialConfig.portHoppingInterval ?: "") }
var bandwidthDown by rememberSaveable { mutableStateOf(initialConfig.bandwidthDown ?: "") }
var bandwidthUp by rememberSaveable { mutableStateOf(initialConfig.bandwidthUp ?: "") }
var network by rememberSaveable { mutableStateOf(initialConfig.network ?: NetworkType.TCP.type) }
var headerType by rememberSaveable { mutableStateOf(initialConfig.headerType ?: "none") }
var host by rememberSaveable { mutableStateOf(initialConfig.host ?: "") }
var path by rememberSaveable { mutableStateOf(initialConfig.path ?: "") }
var seed by rememberSaveable { mutableStateOf(initialConfig.seed ?: "") }
var quicSecurity by rememberSaveable { mutableStateOf(initialConfig.quicSecurity ?: "") }
var quicKey by rememberSaveable { mutableStateOf(initialConfig.quicKey ?: "") }
var mode by rememberSaveable { mutableStateOf(initialConfig.mode ?: "") }
var serviceName by rememberSaveable { mutableStateOf(initialConfig.serviceName ?: "") }
var authority by rememberSaveable { mutableStateOf(initialConfig.authority ?: "") }
var xhttpMode by rememberSaveable { mutableStateOf(initialConfig.xhttpMode ?: "") }
var xhttpExtra by rememberSaveable { mutableStateOf(initialConfig.xhttpExtra ?: "") }
var finalMask by rememberSaveable { mutableStateOf(initialConfig.finalMask ?: "") }
var kcpMtu by rememberSaveable { mutableStateOf(initialConfig.kcpMtu?.toString() ?: "") }
var kcpTti by rememberSaveable { mutableStateOf(initialConfig.kcpTti?.toString() ?: "") }
var browserDialerMode by rememberSaveable { mutableStateOf(initialConfig.browserDialerMode ?: "") }
var streamSecurity by rememberSaveable { mutableStateOf(initialConfig.security ?: "") }
var sni by rememberSaveable { mutableStateOf(initialConfig.sni ?: "") }
var allowInsecure by rememberSaveable { mutableStateOf(initialConfig.insecure == true) }
var fingerPrint by rememberSaveable { mutableStateOf(initialConfig.fingerPrint ?: "") }
var alpn by rememberSaveable { mutableStateOf(initialConfig.alpn ?: "") }
var publicKeyReality by rememberSaveable { mutableStateOf(initialConfig.publicKey ?: "") }
var shortId by rememberSaveable { mutableStateOf(initialConfig.shortId ?: "") }
var spiderX by rememberSaveable { mutableStateOf(initialConfig.spiderX ?: "") }
var mldsa65Verify by rememberSaveable { mutableStateOf(initialConfig.mldsa65Verify ?: "") }
var echConfigList by rememberSaveable { mutableStateOf(initialConfig.echConfigList ?: "") }
var verifyPeerCertByName by rememberSaveable { mutableStateOf(initialConfig.verifyPeerCertByName ?: "") }
var pinnedCA256 by rememberSaveable { mutableStateOf(initialConfig.pinnedCA256 ?: "") }
val isVmess = configType == EConfigType.VMESS
val isVless = configType == EConfigType.VLESS
val isShadowsocks = configType == EConfigType.SHADOWSOCKS
val isSocksOrHttp = configType == EConfigType.SOCKS || configType == EConfigType.HTTP
val isTrojan = configType == EConfigType.TROJAN
val isWireguard = configType == EConfigType.WIREGUARD
val isHysteria2 = configType == EConfigType.HYSTERIA2
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
var isFetchingCert by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
fun buildProfileItem(): ProfileItem = initialConfig.copy(
remarks = remarks,
server = address,
serverPort = port,
password = password,
method = when {
isVmess || isShadowsocks -> method
isVless -> encryption
else -> null
},
flow = if (isVless) flow else null,
username = if (isSocksOrHttp) username else null,
secretKey = if (isWireguard) secretKey else null,
publicKey = when {
isWireguard -> publicKey
streamSecurity == REALITY -> publicKeyReality
else -> null
},
preSharedKey = if (isWireguard) preSharedKey else null,
reserved = if (isWireguard) reserved else null,
localAddress = if (isWireguard) localAddress else null,
mtu = if (isWireguard) mtu.toIntOrNull() else null,
obfsPassword = if (isHysteria2) obfsPassword else null,
portHopping = if (isHysteria2) portHopping else null,
portHoppingInterval = if (isHysteria2) portHoppingInterval else null,
bandwidthDown = if (isHysteria2) bandwidthDown else null,
bandwidthUp = if (isHysteria2) bandwidthUp else null,
network = network,
headerType = headerType,
host = host,
path = path,
seed = seed,
quicSecurity = quicSecurity,
quicKey = quicKey,
mode = mode,
serviceName = serviceName,
authority = authority,
xhttpMode = xhttpMode,
xhttpExtra = xhttpExtra.nullIfBlank(),
finalMask = finalMask.nullIfBlank(),
kcpMtu = kcpMtu.toIntOrNull(),
kcpTti = kcpTti.toIntOrNull(),
browserDialerMode = if (network in listOf(NetworkType.WS.type, NetworkType.XHTTP.type)) browserDialerMode.nullIfBlank() else null,
security = streamSecurity,
sni = sni,
insecure = allowInsecure,
fingerPrint = fingerPrint,
alpn = alpn,
shortId = shortId,
spiderX = spiderX,
mldsa65Verify = mldsa65Verify,
echConfigList = echConfigList,
verifyPeerCertByName = verifyPeerCertByName,
pinnedCA256 = pinnedCA256
)
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
AppTopBar(
title = configType.toString(),
onBackClick = onBackClick,
actions = {
if (guid.isNotEmpty() && !isRunning) {
IconButton(onClick = { showDeleteDialog = true }) {
Icon(painterResource(R.drawable.ic_delete_24dp), stringResource(R.string.acc_delete))
}
}
IconButton(onClick = {
onSave(buildProfileItem())
}) {
Icon(painterResource(R.drawable.ic_fab_check), stringResource(R.string.acc_save))
}
}
)
}
) { innerPadding ->
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.imePadding()
.verticalScrollbar(listState),
contentPadding = PaddingValues(bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it }) }
item { FormTextField(stringResource(R.string.server_lab_address), address, { address = it }) }
if (configType != EConfigType.HYSTERIA2) item { FormTextField(stringResource(R.string.server_lab_port), port, { port = it }, keyboardType = KeyboardType.Number) }
when {
isVmess || isTrojan || isShadowsocks || isHysteria2 -> item {
FormTextField(
stringResource(
when {
isTrojan || isShadowsocks || isHysteria2 -> R.string.server_lab_id3
else -> R.string.server_lab_id
}
), password, { password = it })
}
isVless -> {
item { FormTextField(stringResource(R.string.server_lab_id), password, { password = it }) }
item { FormTextField(stringResource(R.string.server_lab_encryption), encryption, { encryption = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_flow), flow, flowOptions, { flow = it }) }
}
isSocksOrHttp -> {
item { FormTextField(stringResource(R.string.server_lab_security4), username, { username = it }) }
item { FormTextField(stringResource(R.string.server_lab_id4), password, { password = it }) }
}
}
if (isVmess) item { FormDropdownField(stringResource(R.string.server_lab_security), method, securityOptions, { method = it }) }
if (isShadowsocks) item { FormDropdownField(stringResource(R.string.server_lab_security), method, ssSecurityOptions, { method = it }) }
if (isWireguard) {
item { FormTextField(stringResource(R.string.server_lab_secret_key), secretKey, { secretKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_public_key), publicKey, { publicKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_preshared_key), preSharedKey, { preSharedKey = it }) }
item { FormTextField(stringResource(R.string.server_lab_reserved), reserved, { reserved = it }) }
item { FormTextField(stringResource(R.string.server_lab_local_address), localAddress, { localAddress = it }) }
item { FormTextField(stringResource(R.string.server_lab_local_mtu), mtu, { mtu = it }, keyboardType = KeyboardType.Number) }
}
if (isHysteria2) {
item { FormTextField(stringResource(R.string.server_obfs_password), obfsPassword, { obfsPassword = it }) }
item { FormTextField(stringResource(R.string.server_lab_port_hop), portHopping, { portHopping = it }) }
item { FormTextField(stringResource(R.string.server_lab_port_hop_interval), portHoppingInterval, { portHoppingInterval = it }) }
item { FormTextField(stringResource(R.string.server_lab_bandwidth_down), bandwidthDown, { bandwidthDown = it }) }
item { FormTextField(stringResource(R.string.server_lab_bandwidth_up), bandwidthUp, { bandwidthUp = it }) }
}
item { FormDropdownField(stringResource(R.string.server_lab_network), network, networkOptions, { network = it }) }
val headerOptions = when (network) {
NetworkType.TCP.type -> tcpHeaderOptions
NetworkType.KCP.type -> kcpHeaderOptions
NetworkType.GRPC.type -> grpcModeOptions
NetworkType.XHTTP.type -> xhttpModeOptions
else -> listOf("---")
}
if (headerOptions.size > 1) {
item {
FormDropdownField(
stringResource(
when (network) {
NetworkType.GRPC.type -> R.string.server_lab_mode_type; NetworkType.XHTTP.type -> R.string.server_lab_xhttp_mode; else -> R.string.server_lab_head_type
}
),
headerType, headerOptions, { headerType = it }
)
}
}
item {
FormTextField(
stringResource(
when (network) {
NetworkType.TCP.type, NetworkType.HTTP_UPGRADE.type, NetworkType.XHTTP.type, NetworkType.H2.type -> R.string.server_lab_request_host_http
NetworkType.WS.type -> R.string.server_lab_request_host_ws
NetworkType.GRPC.type -> R.string.server_lab_request_host_grpc
else -> R.string.server_lab_request_host6
}
), host, { host = it })
}
item {
FormTextField(
stringResource(
when (network) {
NetworkType.KCP.type -> R.string.server_lab_path_kcp
NetworkType.WS.type -> R.string.server_lab_path_ws
NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
NetworkType.H2.type -> R.string.server_lab_path_h2
NetworkType.GRPC.type -> R.string.server_lab_path_grpc
else -> R.string.server_lab_path
}
), path, { path = it })
}
if (network == NetworkType.XHTTP.type) {
item { FormTextField(stringResource(R.string.server_lab_xhttp_extra), xhttpExtra, { xhttpExtra = it }) }
}
if (network == NetworkType.KCP.type) {
item { FormTextField(stringResource(R.string.server_lab_kcp_mtu), kcpMtu, { kcpMtu = it }, keyboardType = KeyboardType.Number) }
item { FormTextField(stringResource(R.string.server_lab_kcp_tti), kcpTti, { kcpTti = it }, keyboardType = KeyboardType.Number) }
}
item { FormTextField(stringResource(R.string.server_lab_final_mask), finalMask, { finalMask = it }) }
if (network == NetworkType.WS.type || network == NetworkType.XHTTP.type) {
item { FormDropdownField(stringResource(R.string.server_lab_browser_dialer), browserDialerMode, browserDialerOptions, { browserDialerMode = it }) }
}
item { FormDropdownField(stringResource(R.string.server_lab_stream_security), streamSecurity, streamSecurityOptions, { streamSecurity = it }) }
if (streamSecurity.isNotBlank()) {
item { FormTextField(stringResource(R.string.server_lab_sni), sni, { sni = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_stream_fingerprint), fingerPrint, uTlsOptions, { fingerPrint = it }) }
if (streamSecurity == TLS) {
item { SettingsSwitchItem(title = stringResource(R.string.server_lab_allow_insecure), checked = allowInsecure, onCheckedChange = { allowInsecure = it }) }
item { FormDropdownField(stringResource(R.string.server_lab_stream_alpn), alpn, alpnOptions, { alpn = it }) }
item { FormTextField(stringResource(R.string.server_lab_ech_config_list), echConfigList, { echConfigList = it }) }
item { FormTextField(stringResource(R.string.server_lab_verify_peer_cert_by_name), verifyPeerCertByName, { verifyPeerCertByName = it }) }
item { FormTextField(stringResource(R.string.server_lab_pinned_ca256), pinnedCA256, { pinnedCA256 = it }) }
item {
Button(
onClick = {
if (address.isBlank()) {
context.toast(R.string.server_lab_address); return@Button
}
if (configType != EConfigType.HYSTERIA2 && (port.toIntOrNull() ?: 0) <= 0) {
context.toast(R.string.server_lab_port); return@Button
}
val temp = buildProfileItem()
scope.launch {
isFetchingCert = true
try {
val sha256 = withContext(Dispatchers.IO) { CertificateFingerprintManager.fetchForManualFill(temp) }
if (sha256.isNullOrBlank()) context.toast(R.string.toast_fetch_cert_sha256_failed) else {
pinnedCA256 = sha256
context.toastSuccess(R.string.toast_fetch_cert_sha256_success)
}
} finally {
isFetchingCert = false
}
}
},
enabled = !isFetchingCert,
modifier = Modifier.padding(start = 16.dp)
) { Text(stringResource(R.string.pinned_ca256_action_fetch)) }
}
} else if (streamSecurity == REALITY) {
item { FormTextField(stringResource(R.string.server_lab_public_key), publicKeyReality, { publicKeyReality = it }) }
item { FormTextField(stringResource(R.string.server_lab_short_id), shortId, { shortId = it }) }
item { FormTextField(stringResource(R.string.server_lab_spider_x), spiderX, { spiderX = it }) }
item { FormTextField(stringResource(R.string.server_lab_mldsa65_verify), mldsa65Verify, { mldsa65Verify = it }) }
}
}
}
}
if (showDeleteDialog) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
onConfirm = { showDeleteDialog = false; onDelete() },
onDismiss = { showDeleteDialog = false }
)
}
}
@@ -109,7 +109,6 @@ class ServerCustomConfigActivity : BaseComponentActivity() {
content: String
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -207,6 +206,7 @@ fun ServerCustomConfigScreen(
onDelete: () -> Unit
) {
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
val textFieldState = rememberTextFieldState(initialText = initialContent)
var showDeleteConfirm by remember { mutableStateOf(false) }
val showDelete = editGuid.isNotEmpty() && !isRunning
@@ -325,7 +325,15 @@ fun ServerCustomConfigScreen(
)
}
}
IconButton(onClick = { onSave(remarks, textFieldState.text.toString()) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(remarks, textFieldState.text.toString())
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -345,7 +353,8 @@ fun ServerCustomConfigScreen(
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
Box(
@@ -486,6 +495,7 @@ fun ServerCustomConfigScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialRemarks,
onConfirm = {
showDeleteConfirm = false
onDelete()
@@ -116,7 +116,6 @@ class ServerGroupActivity : BaseComponentActivity() {
fallbackTag: String,
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -227,6 +226,7 @@ fun ServerGroupScreen(
val typeEntries = stringArrayResource(R.array.policy_group_type).toList()
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var filter by rememberSaveable { mutableStateOf(initialFilter) }
var typeValue by rememberSaveable { mutableStateOf(typeEntries.getOrNull(initialType).orEmpty()) }
var subValue by rememberSaveable { mutableStateOf(subDisplay.getOrNull(initialSubIndex).orEmpty()) }
@@ -250,9 +250,15 @@ fun ServerGroupScreen(
}
}
IconButton(onClick = {
val typeIdx = typeEntries.indexOf(typeValue).coerceAtLeast(0)
val subIdx = subDisplay.indexOf(subValue).coerceAtLeast(0)
onSave(remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag)
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
val typeIdx = typeEntries.indexOf(typeValue).coerceAtLeast(0)
val subIdx = subDisplay.indexOf(subValue).coerceAtLeast(0)
onSave(remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag)
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
@@ -269,7 +275,12 @@ fun ServerGroupScreen(
.padding(vertical = 8.dp)
.verticalScroll(rememberScrollState())
) {
FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it })
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it },
isError = isRemarksError
)
FormDropdownField(
label = stringResource(R.string.title_policy_group_type),
value = typeValue,
@@ -306,6 +317,7 @@ fun ServerGroupScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_policy_group),
itemName = initialRemarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -1,14 +1,12 @@
package com.v2ray.ang.ui.server
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormTextField
import com.v2ray.ang.ui.compose.SettingsSwitchItem
@@ -18,7 +16,6 @@ class ServerHysteria2Activity : BaseServerActivity() {
@Composable
override fun ScreenContent() {
val scope = rememberCoroutineScope()
val uiState = rememberSaveable(saver = ServerUiState.Saver) {
ServerUiState.from(
initialConfig = initialConfig
@@ -39,7 +36,6 @@ class ServerHysteria2Activity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id3)
return false
}
if (config.security.isNullOrBlank()) {
@@ -51,9 +47,10 @@ class ServerHysteria2Activity : BaseServerActivity() {
@Composable
private fun Hysteria2ProtocolFields(state: ServerUiState) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormTextField(
stringResource(R.string.server_obfs_password),
@@ -100,7 +100,6 @@ class ServerProxyChainActivity : BaseComponentActivity() {
members: List<String>
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
@@ -200,10 +199,11 @@ fun ProxyChainScreen(
onDelete: () -> Unit
) {
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var members by rememberSaveable { mutableStateOf(initialMembers) }
var memberKeys by rememberSaveable { mutableStateOf(List(initialMembers.size) { UUID.randomUUID().toString() }) }
var showProfileDeleteConfirm by remember { mutableStateOf(false) }
var memberToDeleteIndex by rememberSaveable { mutableStateOf<Int?>(null) }
var memberToDeleteKey by rememberSaveable { mutableStateOf<String?>(null) }
val showDelete = editGuid.isNotEmpty() && !isRunning
val lazyListState = rememberLazyListState()
@@ -233,7 +233,15 @@ fun ProxyChainScreen(
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.acc_delete))
}
}
IconButton(onClick = { onSave(remarks, members) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
isRemarksError = remarksErr
val hasError = remarksErr
if (!hasError) {
onSave(remarks, members)
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
}
@@ -272,7 +280,8 @@ fun ProxyChainScreen(
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
}
@@ -285,7 +294,8 @@ fun ProxyChainScreen(
}
itemsIndexed(items = members, key = { index, _ -> memberKeys[index] }) { index, member ->
ReorderableItem(reorderableState, key = memberKeys[index]) { isDragging ->
val memberKey = memberKeys[index]
ReorderableItem(reorderableState, key = memberKey) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row(
@@ -314,10 +324,11 @@ fun ProxyChainScreen(
)
IconButton(onClick = {
if (member.isBlank()) {
members = members.toMutableList().also { it.removeAt(index) }
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
members = remainingMembers
memberKeys = remainingKeys
} else {
memberToDeleteIndex = index
memberToDeleteKey = memberKey
}
}) {
Icon(
@@ -335,19 +346,22 @@ fun ProxyChainScreen(
if (showProfileDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_profile),
itemName = initialRemarks,
onConfirm = { showProfileDeleteConfirm = false; onDelete() },
onDismiss = { showProfileDeleteConfirm = false }
)
}
memberToDeleteIndex?.let { index ->
memberToDeleteKey?.let { memberKey ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_proxy_chain_member),
itemName = members.getOrNull(memberKeys.indexOf(memberKey)).orEmpty(),
onConfirm = {
members = members.toMutableList().also { it.removeAt(index) }
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
memberToDeleteIndex = null
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
members = remainingMembers
memberKeys = remainingKeys
memberToDeleteKey = null
},
onDismiss = { memberToDeleteIndex = null }
onDismiss = { memberToDeleteKey = null }
)
}
}
@@ -52,9 +52,10 @@ class ServerShadowsocksActivity : BaseServerActivity() {
methodOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormDropdownField(
stringResource(R.string.server_lab_security),
@@ -7,7 +7,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormTextField
class ServerTrojanActivity : BaseServerActivity() {
@@ -44,11 +43,9 @@ class ServerTrojanActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id3)
return false
}
if (config.security.isNullOrBlank()) {
toast(R.string.server_lab_stream_security)
return false
}
return true
@@ -57,9 +54,10 @@ class ServerTrojanActivity : BaseServerActivity() {
@Composable
private fun TrojanProtocolFields(state: ServerUiState) {
FormTextField(
stringResource(R.string.server_lab_id3),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id3),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
}
}
@@ -8,6 +8,7 @@ import com.v2ray.ang.AppConfig.DEFAULT_PORT
import com.v2ray.ang.AppConfig.REALITY
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
@@ -30,6 +31,7 @@ class ServerUiState(
reserved: String = "0,0,0",
localAddress: String = WIREGUARD_LOCAL_ADDRESS_V4,
mtu: String = WIREGUARD_LOCAL_MTU,
remoteDNS: String = WIREGUARD_LOCAL_REMOTE_DNS,
obfsPassword: String = "",
portHopping: String = "",
portHoppingInterval: String = "",
@@ -78,6 +80,7 @@ class ServerUiState(
var reserved by mutableStateOf(reserved)
var localAddress by mutableStateOf(localAddress)
var mtu by mutableStateOf(mtu)
var remoteDNS by mutableStateOf(remoteDNS)
var obfsPassword by mutableStateOf(obfsPassword)
var portHopping by mutableStateOf(portHopping)
var portHoppingInterval by mutableStateOf(portHoppingInterval)
@@ -111,6 +114,11 @@ class ServerUiState(
var pinnedCA256 by mutableStateOf(pinnedCA256)
var isFetchingCert by mutableStateOf(isFetchingCert)
var isRemarksError by mutableStateOf(false)
var isAddressError by mutableStateOf(false)
var isPortError by mutableStateOf(false)
var isPasswordError by mutableStateOf(false)
fun toProfileItem(initialConfig: ProfileItem): ProfileItem {
val isVmess = configType == EConfigType.VMESS
val isVless = configType == EConfigType.VLESS
@@ -142,6 +150,7 @@ class ServerUiState(
reserved = if (isWireguard) reserved else null,
localAddress = if (isWireguard) localAddress else null,
mtu = if (isWireguard) mtu.toIntOrNull() else null,
remoteDNS = if (isWireguard) remoteDNS else null,
obfsPassword = if (isHysteria2) obfsPassword else null,
portHopping = if (isHysteria2) portHopping else null,
portHoppingInterval = if (isHysteria2) portHoppingInterval else null,
@@ -199,6 +208,7 @@ class ServerUiState(
reserved = initialConfig.reserved ?: "0,0,0",
localAddress = initialConfig.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4,
mtu = initialConfig.mtu?.toString() ?: WIREGUARD_LOCAL_MTU,
remoteDNS = initialConfig.remoteDNS ?: WIREGUARD_LOCAL_REMOTE_DNS,
obfsPassword = initialConfig.obfsPassword ?: "",
portHopping = initialConfig.portHopping ?: "",
portHoppingInterval = initialConfig.portHoppingInterval ?: "",
@@ -8,7 +8,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
@@ -47,7 +46,6 @@ class ServerVlessActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id)
return false
}
return true
@@ -59,9 +57,10 @@ class ServerVlessActivity : BaseServerActivity() {
flowOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormTextField(
stringResource(R.string.server_lab_encryption),
@@ -8,7 +8,6 @@ import androidx.compose.ui.res.stringResource
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.ui.compose.FormDropdownField
import com.v2ray.ang.ui.compose.FormTextField
@@ -47,7 +46,6 @@ class ServerVmessActivity : BaseServerActivity() {
override fun validateProtocolConfig(config: ProfileItem): Boolean {
if (config.password.isNullOrBlank()) {
toast(R.string.server_lab_id)
return false
}
return true
@@ -59,9 +57,10 @@ class ServerVmessActivity : BaseServerActivity() {
methodOptions: List<String>
) {
FormTextField(
stringResource(R.string.server_lab_id),
state.password,
{ state.password = it }
label = stringResource(R.string.server_lab_id),
value = state.password,
onValueChange = { state.password = it },
isError = state.isPasswordError
)
FormDropdownField(
stringResource(R.string.server_lab_security),
@@ -73,6 +73,12 @@ class ServerWireguardActivity : BaseServerActivity() {
state.finalMask,
{ state.finalMask = it }
)
FormTextField(
stringResource(R.string.server_lab_remote_dns),
state.remoteDNS,
{ state.remoteDNS = it }
)
}
}
@@ -1,7 +1,10 @@
package com.v2ray.ang.ui.settings
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -24,10 +27,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
@@ -44,7 +51,9 @@ import com.v2ray.ang.ui.compose.SettingsMenuItem
import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.ThemeManager
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
class SettingsActivity : BaseComponentActivity() {
@@ -52,6 +61,26 @@ class SettingsActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.refreshSystemVpnSettingsAvailability()
}
}
}
private fun openSystemVpnSettings() {
try {
startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
} catch (error: ActivityNotFoundException) {
reportSystemVpnSettingsFailure(error)
} catch (error: SecurityException) {
reportSystemVpnSettingsFailure(error)
}
}
private fun reportSystemVpnSettingsFailure(error: RuntimeException) {
LogUtil.e(AppConfig.TAG, "Cannot open system VPN settings", error)
toastError(R.string.toast_system_vpn_settings_unavailable)
}
@Composable
@@ -59,7 +88,8 @@ class SettingsActivity : BaseComponentActivity() {
SettingsScreen(
viewModel = viewModel,
onBackClick = { finish() },
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) }
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) },
onSystemVpnSettingsClicked = ::openSystemVpnSettings
)
}
}
@@ -69,10 +99,12 @@ class SettingsActivity : BaseComponentActivity() {
fun SettingsScreen(
viewModel: SettingsViewModel,
onBackClick: () -> Unit,
onModeHelpClicked: () -> Unit
onModeHelpClicked: () -> Unit,
onSystemVpnSettingsClicked: () -> Unit
) {
val scrollState = rememberScrollState()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val systemVpnSettingsAvailable by viewModel.systemVpnSettingsAvailable.collectAsStateWithLifecycle()
var uiSettingsExpanded by rememberSaveable { mutableStateOf(true) }
var vpnSettingsExpanded by rememberSaveable { mutableStateOf(true) }
var coreSettingsExpanded by rememberSaveable { mutableStateOf(true) }
@@ -86,13 +118,13 @@ fun SettingsScreen(
var fakeDns by rememberMmkvBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
var appendHttpProxy by rememberMmkvBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
var vpnDns by rememberMmkvString(AppConfig.PREF_VPN_DNS, "")
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, "0")
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
var vpnInterfaceAddress by rememberMmkvString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")
var vpnMtu by rememberMmkvString(AppConfig.PREF_VPN_MTU, "")
var mux by rememberMmkvBool(AppConfig.PREF_MUX_ENABLED, false)
var muxConcurrency by rememberMmkvString(AppConfig.PREF_MUX_CONCURRENCY, "8")
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
var muxXudpQuic by rememberMmkvString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
var fragment by rememberMmkvBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
@@ -110,7 +142,7 @@ fun SettingsScreen(
var enableRootMode by rememberMmkvBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
var lanSharing by rememberMmkvBool(AppConfig.PREF_ROOT_LAN_SHARING, false)
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, "warning")
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, AppConfig.DEFAULT_HEV_TUNNEL_LOGLEVEL)
var hevTunRwTimeout by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, "")
var useHevTun by rememberMmkvBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
@@ -119,7 +151,7 @@ fun SettingsScreen(
var dynamicSocksPort by rememberMmkvBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
var socksUsername by rememberMmkvString(AppConfig.PREF_SOCKS_USERNAME, "")
var socksPassword by rememberMmkvString(AppConfig.PREF_SOCKS_PASSWORD, "")
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, false)
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, AppConfig.DEFAULT_SOCKS_ENABLE_UDP)
var proxySharing by rememberMmkvBool(AppConfig.PREF_PROXY_SHARING, false)
var speedEnabled by rememberMmkvBool(AppConfig.PREF_SPEED_ENABLED, false)
@@ -142,7 +174,7 @@ fun SettingsScreen(
var domesticDns by rememberMmkvString(AppConfig.PREF_DOMESTIC_DNS, "")
var dnsHosts by rememberMmkvString(AppConfig.PREF_DNS_HOSTS, "")
var coreLogLevel by rememberMmkvString(AppConfig.PREF_LOGLEVEL, "warning")
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "0")
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD)
var isBooted by rememberMmkvBool(AppConfig.PREF_IS_BOOTED, false)
var delayTestUrl by rememberMmkvString(AppConfig.PREF_DELAY_TEST_URL, "")
@@ -153,7 +185,7 @@ fun SettingsScreen(
val hevTunEnabled = isVpn && useHevTun
val localProxyForced = hevTunEnabled
val effectiveLocalProxy = enableLocalProxy || localProxyForced
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: 8
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY.toInt()
val dynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
LaunchedEffect(dynamicColorSupported) {
@@ -612,6 +644,13 @@ fun SettingsScreen(
checked = isBooted,
onCheckedChange = { isBooted = it }
)
if (systemVpnSettingsAvailable) {
SettingsMenuItem(
title = stringResource(R.string.title_system_vpn_settings),
subtitle = stringResource(R.string.summary_system_vpn_settings),
onClick = onSystemVpnSettingsClicked
)
}
SettingsEditItem(
title = stringResource(R.string.title_pref_delay_test_url),
value = delayTestUrl,
@@ -1,15 +1,29 @@
package com.v2ray.ang.ui.settings
import android.app.Application
import android.content.Intent
import android.provider.Settings
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.root.RootManager
import com.v2ray.ang.ui.base.BaseViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
class SettingsViewModel(application: Application) : BaseViewModel(application) {
private val _systemVpnSettingsAvailable = MutableStateFlow(false)
val systemVpnSettingsAvailable = _systemVpnSettingsAvailable.asStateFlow()
suspend fun refreshSystemVpnSettingsAvailability() {
_systemVpnSettingsAvailable.value = withContext(Dispatchers.IO) {
// Android exposes the VPN page, not a direct link to the Always-on VPN switch.
Intent(Settings.ACTION_VPN_SETTINGS).resolveActivity(getApplication<Application>().packageManager) != null
}
}
/**
* Checks for root access and requests it if necessary.
* Updates [isLoading] during the process.
@@ -56,4 +70,4 @@ class SettingsViewModel(application: Application) : BaseViewModel(application) {
null
}
}
}
}
@@ -39,10 +39,14 @@ import com.v2ray.ang.ui.compose.SettingsSwitchItem
import com.v2ray.ang.ui.compose.verticalScrollbar
import com.v2ray.ang.util.LogUtil
data class TaskerItem(
val label: String,
val guid: String,
)
class TaskerActivity : BaseComponentActivity() {
private var lstData: ArrayList<String> = ArrayList()
private var lstGuid: ArrayList<String> = ArrayList()
private val items = mutableListOf<TaskerItem>()
private val switchState = mutableStateOf(false)
private val selectedPosition = mutableStateOf(-1)
@@ -50,13 +54,11 @@ class TaskerActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
items.add(TaskerItem(label = "Default", guid = AppConfig.TASKER_DEFAULT_GUID))
MmkvManager.decodeAllServerList().forEach { key ->
MmkvManager.decodeServerConfig(key)?.let { config ->
lstData.add(config.remarks)
lstGuid.add(key)
items.add(TaskerItem(label = config.remarks, guid = key))
}
}
@@ -66,7 +68,7 @@ class TaskerActivity : BaseComponentActivity() {
@Composable
override fun ScreenContent() {
TaskerScreen(
items = lstData,
items = items,
switchState = switchState,
selectedPosition = selectedPosition,
onBackClick = { finish() },
@@ -84,10 +86,7 @@ class TaskerActivity : BaseComponentActivity() {
return
} else {
switchState.value = switch
val pos = lstGuid.indexOf(guid.toString())
if (pos >= 0) {
selectedPosition.value = pos
}
selectedPosition.value = items.indexOfFirst { it.guid == guid.toString() }
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
@@ -102,13 +101,12 @@ class TaskerActivity : BaseComponentActivity() {
val extraBundle = Bundle()
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, switchState.value)
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, items[position].guid)
val intent = Intent()
val remarks = lstData[position]
val blurb = getString(
if (switchState.value) R.string.tasker_blurb_start else R.string.tasker_blurb_stop,
remarks
items[position].label
)
intent.putExtra(AppConfig.TASKER_EXTRA_BUNDLE, extraBundle)
@@ -120,7 +118,7 @@ class TaskerActivity : BaseComponentActivity() {
@Composable
fun TaskerScreen(
items: List<String>,
items: List<TaskerItem>,
switchState: MutableState<Boolean>,
selectedPosition: MutableState<Int>,
onBackClick: () -> Unit,
@@ -158,7 +156,7 @@ fun TaskerScreen(
.verticalScrollbar(listState),
contentPadding = NavigationBarsBottomPadding()
) {
itemsIndexed(items) { index, remarks ->
itemsIndexed(items, key = { _, item -> item.guid }) { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
@@ -171,7 +169,7 @@ fun TaskerScreen(
onClick = { selectedPosition.value = index }
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = remarks, style = MaterialTheme.typography.bodyLarge)
Text(text = item.label, style = MaterialTheme.typography.bodyLarge)
}
}
}
@@ -29,7 +29,6 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
@@ -78,26 +77,19 @@ class SubEditActivity : BaseComponentActivity() {
}
private fun saveServer(subItem: SubscriptionItem): Boolean {
if (TextUtils.isEmpty(subItem.remarks)) {
toast(R.string.sub_setting_remarks)
return false
}
if (subItem.url.isNotEmpty()) {
if (!Utils.isValidUrl(subItem.url)) {
toast(R.string.toast_invalid_url)
return false
}
if (!Utils.isValidSubUrl(subItem.url)) {
toast(R.string.toast_insecure_url_protocol)
if (!subItem.allowInsecureUrl) {
return false
}
if (!Utils.isValidSubUrl(subItem.url) && !subItem.allowInsecureUrl) {
return false
}
}
if (subItem.autoUpdate && subItem.updateInterval < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
toast(R.string.toast_invalid_update_interval)
return false
}
@@ -132,13 +124,16 @@ fun SubEditScreen(
) {
//val context = LocalContext.current
var remarks by rememberSaveable { mutableStateOf(initial.remarks.orEmpty()) }
var isRemarksError by rememberSaveable { mutableStateOf(false) }
var url by rememberSaveable { mutableStateOf(initial.url.orEmpty()) }
var isUrlError by rememberSaveable { mutableStateOf(false) }
var userAgent by rememberSaveable { mutableStateOf(initial.userAgent.orEmpty()) }
var requestHeaders by rememberSaveable { mutableStateOf(initial.requestHeaders.orEmpty()) }
var filter by rememberSaveable { mutableStateOf(initial.filter ?: "") }
var enabled by rememberSaveable { mutableStateOf(initial.enabled) }
var autoUpdate by rememberSaveable { mutableStateOf(initial.autoUpdate) }
var updateInterval by rememberSaveable { mutableStateOf(initial.updateInterval.toString()) }
var isUpdateIntervalError by rememberSaveable { mutableStateOf(false) }
var allowInsecureUrl by rememberSaveable { mutableStateOf(initial.allowInsecureUrl) }
var prevProfile by rememberSaveable { mutableStateOf(initial.prevProfile ?: "") }
var nextProfile by rememberSaveable { mutableStateOf(initial.nextProfile ?: "") }
@@ -177,7 +172,22 @@ fun SubEditScreen(
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.acc_delete))
}
}
IconButton(onClick = { buildSubItem()?.let { onSave(it) } }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
val urlErr = url.isNotEmpty() && (
!Utils.isValidUrl(url) || (!Utils.isValidSubUrl(url) && !allowInsecureUrl)
)
val intervalErr = autoUpdate && updateInterval.toLongEx() < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES
isRemarksError = remarksErr
isUrlError = urlErr
isUpdateIntervalError = intervalErr
val hasError = remarksErr || urlErr || intervalErr
if (!hasError) {
onSave(buildSubItem())
}
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.acc_save))
}
}
@@ -195,8 +205,19 @@ fun SubEditScreen(
.padding(vertical = 8.dp)
.padding(bottom = 36.dp)
) {
FormTextField(stringResource(R.string.sub_setting_remarks), remarks, { remarks = it })
FormTextField(stringResource(R.string.sub_setting_url), url, { url = it })
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it },
isError = isRemarksError
)
FormTextField(
label = stringResource(R.string.sub_setting_url),
value = url,
onValueChange = { url = it },
isError = isUrlError,
supportingText = if (isUrlError) stringResource(R.string.toast_invalid_url) else null
)
FormTextField(stringResource(R.string.sub_setting_user_agent), userAgent, { userAgent = it })
FormTextField(stringResource(R.string.sub_setting_request_headers), requestHeaders, { requestHeaders = it })
FormTextField(stringResource(R.string.sub_setting_filter), filter, { filter = it })
@@ -213,8 +234,12 @@ fun SubEditScreen(
)
FormTextField(
stringResource(R.string.title_pref_auto_update_interval),
updateInterval, { updateInterval = it }, keyboardType = KeyboardType.Number
label = stringResource(R.string.title_pref_auto_update_interval),
value = updateInterval,
onValueChange = { updateInterval = it },
keyboardType = KeyboardType.Number,
isError = isUpdateIntervalError,
supportingText = if (isUpdateIntervalError) stringResource(R.string.toast_invalid_update_interval) else null
)
SettingsSwitchItem(
@@ -247,6 +272,7 @@ fun SubEditScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_subscription_group),
itemName = initial.remarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui.subscription
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import androidx.activity.viewModels
import androidx.annotation.StringRes
@@ -53,7 +52,6 @@ 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.verticalScrollbar
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -63,6 +61,8 @@ private enum class SubscriptionShareAction(@StringRes val labelRes: Int) {
Clipboard(R.string.share_subscription_clipboard)
}
private data class SubscriptionDeleteTarget(val guid: String, val name: String)
class SubSettingActivity : BaseComponentActivity() {
private val viewModel: SubscriptionsViewModel by viewModels()
@@ -83,7 +83,7 @@ class SubSettingActivity : BaseComponentActivity() {
startActivity(Intent(this, SubEditActivity::class.java).putExtra("subId", subId))
},
onRemoveSub = { subId -> removeSub(subId) },
onShareQRCode = { url -> QRCodeDecoder.createQRCode(url) },
onShareQRCode = viewModel::shareQRCode,
onShareClipboard = { url ->
Utils.setClipboard(this, url)
toast(getString(R.string.toast_success))
@@ -110,16 +110,16 @@ fun SubSettingScreen(
onSubUpdate: () -> Unit,
onEditSub: (String) -> Unit,
onRemoveSub: (String) -> Unit,
onShareQRCode: (String) -> Bitmap?,
onShareQRCode: (String) -> Unit,
onShareClipboard: (String) -> Unit
) {
val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle()
var showUpdateDialog by remember { mutableStateOf(false) }
var removeTarget by remember { mutableStateOf<String?>(null) }
var removeTarget by remember { mutableStateOf<SubscriptionDeleteTarget?>(null) }
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
var shareTarget by remember { mutableStateOf<Pair<String, String>?>(null) }
var showQRCodeBitmap by remember { mutableStateOf<Bitmap?>(null) }
val qrCodeBitmap by viewModel.qrCode.collectAsStateWithLifecycle()
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
@@ -214,7 +214,7 @@ fun SubSettingScreen(
)
}
IconButton(onClick = {
if (confirmRemove) removeTarget = subCache.guid
if (confirmRemove) removeTarget = SubscriptionDeleteTarget(subCache.guid, subCache.subscription.remarks)
else onRemoveSub(subCache.guid)
}) {
Icon(
@@ -254,7 +254,7 @@ fun SubSettingScreen(
onSelected = { action ->
shareTarget = null
when (action) {
SubscriptionShareAction.QRCode -> showQRCodeBitmap = onShareQRCode(url)
SubscriptionShareAction.QRCode -> onShareQRCode(url)
SubscriptionShareAction.Clipboard -> onShareClipboard(url)
}
},
@@ -263,19 +263,20 @@ fun SubSettingScreen(
}
// QR Code Dialog
if (showQRCodeBitmap != null) {
if (qrCodeBitmap != null) {
QRCodeDialog(
bitmap = showQRCodeBitmap,
onDismiss = { showQRCodeBitmap = null }
bitmap = qrCodeBitmap,
onDismiss = viewModel::dismissQRCode
)
}
if (removeTarget != null) {
removeTarget?.let { target ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_subscription_group),
itemName = target.name,
onConfirm = {
onRemoveSub(removeTarget!!)
removeTarget = null
onRemoveSub(target.guid)
},
onDismiss = { removeTarget = null }
)
@@ -1,6 +1,8 @@
package com.v2ray.ang.ui.subscription
import android.app.Application
import android.graphics.Bitmap
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.SubscriptionUpdateMessage
@@ -14,14 +16,20 @@ import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SubscriptionsViewModel(application: Application) : BaseViewModel(application) {
private var qrCodeJob: Job? = null
private val _qrCode = MutableStateFlow<Bitmap?>(null)
internal val qrCode = _qrCode.asStateFlow()
private val subscriptions: MutableList<SubscriptionCache> =
MmkvManager.decodeSubscriptions().toMutableList()
@@ -55,6 +63,21 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
_subsFlow.value = subscriptions.toList()
}
internal fun shareQRCode(url: String) {
dismissQRCode()
qrCodeJob = viewModelScope.launch {
val bitmap = withContext(Dispatchers.Default) { QRCodeDecoder.createQRCode(url) }
if (bitmap == null) toastError(R.string.toast_failure)
_qrCode.value = bitmap
}
}
internal fun dismissQRCode() {
qrCodeJob?.cancel()
qrCodeJob = null
_qrCode.value = null
}
fun move(fromPosition: Int, toPosition: Int) {
if (subscriptions.moveItem(fromPosition, toPosition)) {
MmkvManager.encodeSubsList(subscriptions.mapTo(mutableListOf()) { it.guid })
@@ -87,7 +110,13 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
toast(R.string.title_update_subscription_no_subscription)
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
toast(getString(R.string.title_update_config_count, result.configCount))
toast(
getQuantityString(
R.plurals.title_update_config_count,
result.configCount,
result.configCount,
)
)
else ->
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
@@ -4,6 +4,8 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.text.format.DateFormat
import android.text.format.Formatter
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
@@ -34,6 +36,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -45,7 +49,6 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
@@ -66,7 +69,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
private enum class AddAssetMenuAction(@StringRes val labelRes: Int) {
@@ -219,7 +222,13 @@ class UserAssetActivity : HelperBaseComponentActivity() {
viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
}
if (result.successCount > 0) {
toast(getString(R.string.title_update_asset_count, result.successCount))
toast(
resources.getQuantityString(
R.plurals.title_update_asset_count,
result.successCount,
result.successCount,
)
)
} else {
toast(getString(R.string.toast_failure))
}
@@ -336,7 +345,8 @@ internal fun UserAssetScreen(
deleteTarget?.let { asset ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_asset_file, asset.name),
message = stringResource(R.string.confirm_delete_asset_file),
itemName = asset.name,
onConfirm = {
deleteTarget = null
onRemoveAsset(asset.guid, asset.name)
@@ -353,11 +363,14 @@ private fun UserAssetItem(
onEdit: () -> Unit,
onDeleteClick: () -> Unit
) {
val context = LocalContext.current
val locale = LocalConfiguration.current.locales[0]
val propertiesText = if (fileMetadata != null) {
remember(fileMetadata) {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
"${fileMetadata.length.toTrafficString()} • ${dateFormat.format(Date(fileMetadata.lastModified))}"
}
val fileSize = Formatter.formatFileSize(context, fileMetadata.length)
val skeleton = if (DateFormat.is24HourFormat(context)) "yMMMdHm" else "yMMMdhm"
val formattedDate = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
.format(Date(fileMetadata.lastModified))
"$fileSize • $formattedDate"
} else {
stringResource(R.string.msg_file_not_found)
}
@@ -4,15 +4,19 @@ 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
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -81,6 +85,15 @@ class UserAssetUrlActivity : BaseComponentActivity() {
}
private fun saveServer(remarks: String, url: String): Boolean {
val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.assetUrl.remarks == remarks && it.guid != editAssetId }) {
toast(R.string.msg_remark_is_duplicate)
return false
}
if (TextUtils.isEmpty(remarks) || TextUtils.isEmpty(url)) {
return false
}
var assetItem = MmkvManager.decodeAsset(editAssetId)
var assetId = editAssetId
if (assetItem != null) {
@@ -100,20 +113,6 @@ class UserAssetUrlActivity : BaseComponentActivity() {
assetItem.remarks = remarks
assetItem.url = url
val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
toast(R.string.msg_remark_is_duplicate)
return false
}
if (TextUtils.isEmpty(assetItem.remarks)) {
toast(R.string.sub_setting_remarks)
return false
}
if (TextUtils.isEmpty(assetItem.url)) {
toast(R.string.title_url)
return false
}
MmkvManager.encodeAsset(assetId, assetItem)
toastSuccess(R.string.toast_success)
finish()
@@ -138,9 +137,11 @@ fun UserAssetUrlScreen(
onSave: (String, String) -> Boolean,
onDelete: () -> Unit
) {
var remarks by remember { mutableStateOf(initialRemarks) }
var url by remember { mutableStateOf(initialUrl) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var remarks by rememberSaveable(editAssetId, initialRemarks) { mutableStateOf(initialRemarks) }
var isRemarksError by rememberSaveable(editAssetId) { mutableStateOf(false) }
var url by rememberSaveable(editAssetId, initialUrl) { mutableStateOf(initialUrl) }
var isUrlError by rememberSaveable(editAssetId) { mutableStateOf(false) }
var showDeleteConfirm by rememberSaveable(editAssetId) { mutableStateOf(false) }
Scaffold(
contentWindowInsets = WindowInsets(0),
@@ -157,7 +158,18 @@ fun UserAssetUrlScreen(
)
}
}
IconButton(onClick = { onSave(remarks, url) }) {
IconButton(onClick = {
val remarksErr = remarks.isBlank()
val urlErr = url.isBlank()
isRemarksError = remarksErr
isUrlError = urlErr
val hasError = remarksErr || urlErr
if (!hasError) {
onSave(remarks, url)
}
}) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.acc_save)
@@ -171,17 +183,22 @@ fun UserAssetUrlScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp)
) {
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it }
onValueChange = { remarks = it },
isError = isRemarksError
)
FormTextField(
label = stringResource(R.string.title_url),
value = url,
onValueChange = { url = it }
onValueChange = { url = it },
isError = isUrlError
)
NavigationBarsSpacer()
}
@@ -190,6 +207,7 @@ fun UserAssetUrlScreen(
if (showDeleteConfirm) {
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_asset_source),
itemName = initialRemarks,
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
@@ -11,7 +11,6 @@ import com.v2ray.ang.handler.MmkvManager
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
@@ -50,7 +49,7 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
}.also { reloadJob = it }
}
private fun buildAssetList(
internal fun buildAssetList(
decodedAssets: List<AssetUrlCache>?,
geoFilesSource: String
): List<AssetUrlCache> {
@@ -59,7 +58,8 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
.filter { geoFile -> savedAssets.none { it.assetUrl.remarks == geoFile } }
.map {
AssetUrlCache(
Utils.getUuid(),
// Built-in rows have no persisted GUID; keep their UI identity across reloads.
"builtin:$it",
AssetUrlItem(
it,
String.format(AppConfig.GITHUB_DOWNLOAD_URL, geoFilesSource).concatUrl(it),
@@ -1,12 +1,13 @@
package com.v2ray.ang.util
import android.content.Context
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.Options
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.request.Options
import com.v2ray.ang.AppConfig
class AppIconFetcher(
@@ -25,8 +26,8 @@ class AppIconFetcher(
null
} ?: return null
return DrawableResult(
drawable = drawable,
return ImageFetchResult(
image = drawable.asImage(),
isSampled = false,
dataSource = DataSource.DISK
)
+28 -7
View File
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">اسحب لأسفل للتحديث!</string>
<!-- Notifications -->
<string name="notification_channel_service">خدمة v2rayNG</string>
<string name="notification_channel_subscription_updates">تحديثات الاشتراكات</string>
<string name="notification_channel_connection_checks">فحوص الاتصال</string>
<string name="notification_action_stop_v2ray">إيقاف</string>
<string name="toast_permission_denied">تعذر الحصول على الإذن</string>
<string name="toast_permission_denied_notification">تعذر الحصول على إذن الإشعارات</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">هل أنت متأكد من أنك تريد حذف هذا العضو من سلسلة الوكلاء؟</string>
<string name="confirm_delete_routing_rule">هل أنت متأكد من أنك تريد حذف قاعدة التوجيه هذه؟</string>
<string name="confirm_delete_subscription_group">هل أنت متأكد من أنك تريد حذف مجموعة الاشتراك هذه؟</string>
<string name="confirm_delete_asset_file">هل أنت متأكد من أنك تريد حذف ملف الأصول \"%1$s\"؟</string>
<string name="confirm_delete_asset_file">هل أنت متأكد من أنك تريد حذف ملف الأصول هذا؟</string>
<string name="confirm_delete_asset_source">هل أنت متأكد من أنك تريد حذف مصدر الأصول هذا؟</string>
<string name="server_lab_remarks">ملاحظات</string>
<string name="server_lab_address">العنوان</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved (اختياري، مفصول بفواصل)</string>
<string name="server_lab_local_address">العنوان المحلي (اختياري IPv4/IPv6، مفصولة بفواصل)</string>
<string name="server_lab_local_mtu">MTU (اختياري، الافتراضي 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي في النواة 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي في النواة 50)</string>
<string name="server_lab_remote_dns">remoteDNS (اختياري، عناوين IPv4/IPv6 مفصولة بفواصل، أو "local" لاستخدام DNS الجهاز)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختياري، الافتراضي 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختياري، الافتراضي 50 مللي ثانية)</string>
<string name="toast_success">نجاح</string>
<string name="toast_failure">فشل</string>
<string name="toast_none_data">لا يوجد شيء</string>
@@ -172,8 +176,11 @@
<string name="title_vpn_settings">إعدادات VPN</string>
<string name="title_pref_per_app_proxy">الوكيل لكل تطبيق</string>
<string name="summary_pref_per_app_proxy">عام: تستخدم التطبيقات المحددة الوكيل، وتتصل التطبيقات غير المحددة مباشرة؛\nوضع التجاوز: تتصل التطبيقات المحددة مباشرة، وتستخدم التطبيقات غير المحددة الوكيل.\nيتوفر في القائمة خيار لتحديد التطبيقات التي ستستخدم الوكيل تلقائيًا.</string>
<string name="title_pref_is_booted">الاتصال تلقائياً عند بدء التشغيل</string>
<string name="summary_pref_is_booted">الاتصال تلقائياً بالخادم المحدد عند بدء التشغيل؛ قد لا تنجح المحاولة</string>
<string name="title_pref_is_booted">الاتصال بعد إقلاع الجهاز</string>
<string name="summary_pref_is_booted">الاتصال تلقائياً بالخادم المحدد بعد إقلاع الجهاز. قد يقيّد Android بدء التشغيل في الخلفية.</string>
<string name="title_system_vpn_settings">إعدادات VPN للنظام</string>
<string name="summary_system_vpn_settings">فتح إعدادات VPN في Android لإعداد VPN الدائم، إذا كان الجهاز يدعمه.</string>
<string name="toast_system_vpn_settings_unavailable">إعدادات VPN للنظام غير متاحة.</string>
<string name="subscription_updater_job_tips">ستعمل المهمة في الخلفية، وسيُعرض تقدمها في الإشعار.</string>
@@ -348,8 +355,22 @@
<string name="title_del_config_count">حذف %d من التكوينات</string>
<string name="title_import_config_count">استيراد %d من التكوينات</string>
<string name="title_export_config_count">تصدير %d من التكوينات</string>
<string name="title_update_config_count">تحديث %d من التكوينات</string>
<string name="title_update_asset_count">تم تحديث %d من ملفات الأصول</string>
<plurals name="title_update_config_count">
<item quantity="zero">لم يتم تحديث أي تكوينات</item>
<item quantity="one">تم تحديث تكوين واحد</item>
<item quantity="two">تم تحديث تكوينين</item>
<item quantity="few">تم تحديث %1$d تكوينات</item>
<item quantity="many">تم تحديث %1$d تكوينًا</item>
<item quantity="other">تم تحديث %1$d تكوين</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="zero">لم يتم تحديث أي ملفات أصول</item>
<item quantity="one">تم تحديث ملف أصول واحد</item>
<item quantity="two">تم تحديث ملفي أصول</item>
<item quantity="few">تم تحديث %1$d ملفات أصول</item>
<item quantity="many">تم تحديث %1$d ملف أصول</item>
<item quantity="other">تم تحديث %1$d ملف أصول</item>
</plurals>
<string name="title_update_subscription_result">تم تحديث %1$d إعدادات (%2$d ناجحة، %3$d فاشلة، %4$d متخطاة)</string>
<string name="title_update_subscription_no_subscription">لا توجد اشتراكات</string>
<string name="toast_server_not_found_in_group">لم يُعثر على الخادم المحدد في المجموعة الحالية</string>
+20 -7
View File
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">রিফ্রেশ করতে নিচে টানুন!</string>
<!-- Notifications -->
<string name="notification_channel_service">v2rayNG সার্ভিস</string>
<string name="notification_channel_subscription_updates">সাবস্ক্রিপশন আপডেট</string>
<string name="notification_channel_connection_checks">সংযোগ পরীক্ষা</string>
<string name="notification_action_stop_v2ray">বন্ধ করুন</string>
<string name="toast_permission_denied">অনুমতি পাওয়া যাচ্ছে না</string>
<string name="toast_permission_denied_notification">বিজ্ঞপ্তির অনুমতি পাওয়া যায়নি</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">আপনি কি এই প্রক্সি চেইন সদস্যটিকে মুছে ফেলতে চান?</string>
<string name="confirm_delete_routing_rule">আপনি কি এই রাউটিং নিয়মটি মুছে ফেলতে চান?</string>
<string name="confirm_delete_subscription_group">আপনি কি এই সাবস্ক্রিপশন গ্রুপটি মুছে ফেলতে চান?</string>
<string name="confirm_delete_asset_file">আপনি কি \"%1$s\" অ্যাসেট ফাইলটি মুছে ফেলতে চান?</string>
<string name="confirm_delete_asset_file">আপনি কি এই অ্যাসেট ফাইলটি মুছে ফেলতে চান?</string>
<string name="confirm_delete_asset_source">আপনি কি এই অ্যাসেট উৎসটি মুছে ফেলতে চান?</string>
<string name="server_lab_remarks">মন্তব্য</string>
<string name="server_lab_address">ঠিকানা</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved (ঐচ্ছিক, কমা দিয়ে পৃথক)</string>
<string name="server_lab_local_address">স্থানীয় ঠিকানা (ঐচ্ছিক IPv4/IPv6, কমা দ্বারা পৃথক করা)</string>
<string name="server_lab_local_mtu">MTU (ঐচ্ছিক, ডিফল্ট 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, কোর ডিফল্ট 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, কোর ডিফল্ট 50)</string>
<string name="server_lab_remote_dns">remoteDNS (ঐচ্ছিক, কমা দ্বারা পৃথক করা IPv4/IPv6 ঠিকানা, অথবা ডিভাইসের DNS ব্যবহার করতে "local")</string>
<string name="server_lab_kcp_mtu">mKCP MTU (ঐচ্ছিক, ডিফল্ট 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (ঐচ্ছিক, ডিফল্ট 50 মিলিসেকেন্ড)</string>
<string name="toast_success">সফল</string>
<string name="toast_failure">ব্যর্থ</string>
<string name="toast_none_data">কোনও তথ্য নেই</string>
@@ -172,8 +176,11 @@
<string name="title_vpn_settings">VPN সেটিংস</string>
<string name="title_pref_per_app_proxy">প্রতি-অ্যাপ প্রক্সি</string>
<string name="summary_pref_per_app_proxy">সাধারণ: নির্বাচিত অ্যাপ প্রক্সি ব্যবহার করে, অনির্বাচিত অ্যাপ সরাসরি সংযুক্ত হয়;\nবাইপাস মোড: নির্বাচিত অ্যাপ সরাসরি সংযুক্ত হয়, অনির্বাচিত অ্যাপ প্রক্সি ব্যবহার করে।\nযেসব অ্যাপ প্রক্সি ব্যবহার করবে, সেগুলি স্বয়ংক্রিয়ভাবে নির্বাচনের বিকল্পটি মেনুতে আছে।</string>
<string name="title_pref_is_booted">চালুর সময় স্বয়ংক্রিয় সংযোগ</string>
<string name="summary_pref_is_booted">চালুর সময় নির্বাচিত সার্ভারে স্বয়ংক্রিয়ভাবে সংযোগ করে; সংযোগ ব্যর্থ হতে পারে</string>
<string name="title_pref_is_booted">ডিভাইস চালু হওয়ার পর সংযোগ</string>
<string name="summary_pref_is_booted">ডিভাইস চালু হওয়ার পর নির্বাচিত সার্ভারে স্বয়ংক্রিয়ভাবে সংযোগ করুন। Android ব্যাকগ্রাউন্ডে চালু হওয়া সীমিত করতে পারে।</string>
<string name="title_system_vpn_settings">সিস্টেমের VPN সেটিংস</string>
<string name="summary_system_vpn_settings">ডিভাইসে সমর্থিত হলে সর্বদা চালু VPN কনফিগার করতে Android-এর VPN সেটিংস খুলুন।</string>
<string name="toast_system_vpn_settings_unavailable">সিস্টেমের VPN সেটিংস উপলভ্য নয়।</string>
<string name="subscription_updater_job_tips">কাজটি ব্যাকগ্রাউন্ডে চলবে এবং অগ্রগতির তথ্য বিজ্ঞপ্তিতে দেখানো হবে।</string>
@@ -348,8 +355,14 @@
<string name="title_del_config_count">%dটি কনফিগারেশন মুছুন</string>
<string name="title_import_config_count">%dটি কনফিগারেশন আমদানি করুন</string>
<string name="title_export_config_count">%dটি কনফিগারেশন রপ্তানি করুন</string>
<string name="title_update_config_count">%dটি কনফিগারেশন আপডেট করুন</string>
<string name="title_update_asset_count">%dটি অ্যাসেট ফাইল আপডেট হয়েছে</string>
<plurals name="title_update_config_count">
<item quantity="one">%1$dটি কনফিগারেশন আপডেট হয়েছে</item>
<item quantity="other">%1$dটি কনফিগারেশন আপডেট হয়েছে</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">%1$dটি অ্যাসেট ফাইল আপডেট হয়েছে</item>
<item quantity="other">%1$dটি অ্যাসেট ফাইল আপডেট হয়েছে</item>
</plurals>
<string name="title_update_subscription_result">%1$dটি কনফিগ আপডেট হয়েছে (%2$d সফল, %3$d ব্যর্থ, %4$d এড়ানো)</string>
<string name="title_update_subscription_no_subscription">কোনো সাবস্ক্রিপশন নেই</string>
<string name="toast_server_not_found_in_group">বর্তমান গ্রুপে নির্বাচিত সার্ভারটি পাওয়া যায়নি</string>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">سی وانۊ کردن، بلگه ن بلم بکشین!</string>
<!-- Notifications -->
<string name="notification_channel_service">سرویس v2rayNG</string>
<string name="notification_channel_subscription_updates">ورۊ کردن اشتراکا</string>
<string name="notification_channel_connection_checks">واجۊری منپیز</string>
<string name="notification_action_stop_v2ray">واڌاشتن</string>
<string name="toast_permission_denied">گرؽڌن موجوز مومکن نؽڌ</string>
<string name="toast_permission_denied_notification">گرؽڌن موجوز وارسۊوی مومکن نؽڌ</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">ای عوزو زنجیره پروکسی پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_routing_rule">ای قانووݩ تور جوستن پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_subscription_group">ای بونکۊ اشتراک پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_asset_file">فایل بونچک جغرافیایی «%1$s» پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_asset_file">ای فایل بونچک جغرافیایی پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="confirm_delete_asset_source">ای بونچک فایلا جغرافیایی پاک ابۊ، هنی هم اخۊی پاکس کۊنی؟</string>
<string name="server_lab_remarks">نیشتنا</string>
<string name="server_lab_address">نشۊوی</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved(اختیاری، وا کاما (,) ز یک جوڌا ابۊن)</string>
<string name="server_lab_local_address">نشۊوی محلی (اختیاری IPv4/IPv6، وا کاما (,) ز یک جوڌا ابۊن)</string>
<string name="server_lab_local_mtu">MTU(اختیاری، پؽش فرض 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرض هسته 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرض هسته 50)</string>
<string name="server_lab_remote_dns">remoteDNS (اختياری، آدرس‌های IPv4/IPv6 جدا شده با کاما، یا "local" برای استفاده از DNS دستگاه)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرض 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرض 50 میلی‌ثانیه)</string>
<string name="toast_success">وا مووفقیت ٱنجوم وابی</string>
<string name="toast_failure">شکست خرد</string>
<string name="toast_none_data">هیچ داڌه ای وۊجۊڌ نڌاره</string>
@@ -172,8 +176,11 @@
<string name="title_vpn_settings">سامووا VPN</string>
<string name="title_pref_per_app_proxy">پروکسی و ری برنومه</string>
<string name="summary_pref_per_app_proxy">پوی وولاتی: برنومه پسند وابیڌه وا ی پروکسی منپیز ابۊ، برنومه پسند نوابیڌه موستقیمن منپیز ابۊ.\nحالت دور زیڌن: برنومه پسند وابیڌه موستقیمن منپیز ابۊ، برنومه پسند نوابیڌه وا ی پروکسی منپیز ابۊ.\nپسند خوتکار برنومه یلی ک وا ی پروکسی منپیز ابۊن من نومگه امکووݩ پذیر هڌ.</string>
<string name="title_pref_is_booted">منپیز خوتکار مجال ره ونی</string>
<string name="summary_pref_is_booted">مجال ره وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ.</string>
<string name="title_pref_is_booted">منپیز بئڌ ره وندن دسگا</string>
<string name="summary_pref_is_booted">بئڌ ره وندن دسگا، خوساخوس و سرور پسند بیڌه منپیز ابۊ. Android گاشڌ ره وندن من پس زمینه ن مئدۊد کونه.</string>
<string name="title_system_vpn_settings">سامووا VPN سیستوم</string>
<string name="summary_system_vpn_settings">سامووا VPN من Android ن وا کۊنین سی ساموو کردن VPN همیشه ره وست، ٱر دسگا زس پشتیبانی اکونه.</string>
<string name="toast_system_vpn_settings_unavailable">سامووا VPN سیستوم من دسرس نین.</string>
<string name="subscription_updater_job_tips">وزیفه من پس زمینه کار اکونه وو ایلاعات پؽشرفت من اعلان نشووݩ داڌه ابۊ.</string>
@@ -348,8 +355,14 @@
<string name="title_del_config_count">پاک کردن %d کانفیگ</string>
<string name="title_import_config_count">و من ٱووردن %d کانفیگ</string>
<string name="title_export_config_count">و در کشیڌن %d کانفیگ</string>
<string name="title_update_config_count">ورۊ کردن %d کانفیگ</string>
<string name="title_update_asset_count">%d فایل بونچک جغرافیایی ورۊ وابی</string>
<plurals name="title_update_config_count">
<item quantity="one">%1$d کانفیگ ورۊ وابی</item>
<item quantity="other">%1$d کانفیگ ورۊ وابی</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">%1$d فایل بونچک جوقرافیایی ورۊ وابی</item>
<item quantity="other">%1$d فایل بونچک جوقرافیایی ورۊ وابی</item>
</plurals>
<string name="title_update_subscription_result">%1$d کانفیگ ورۊ وابی (مووفق: %2$d، شکست خرده: %3$d، رڌ وابیڌه: %4$d)</string>
<string name="title_update_subscription_no_subscription">بؽ اشتراک</string>
<string name="toast_server_not_found_in_group">سرور پسند وابیڌه من بونکۊ سکویی نجۊرست</string>
+20 -7
View File
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">برای بروزرسانی، صفحه را به پایین بکشید!</string>
<!-- Notifications -->
<string name="notification_channel_service">سرویس v2rayNG</string>
<string name="notification_channel_subscription_updates">به‌روزرسانی اشتراک‌ها</string>
<string name="notification_channel_connection_checks">بررسی اتصال</string>
<string name="notification_action_stop_v2ray">توقف</string>
<string name="toast_permission_denied">امکان دریافت مجوز وجود ندارد</string>
<string name="toast_permission_denied_notification">امکان دریافت مجوز ارسال اعلان وجود ندارد</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">آیا مطمئن هستید که می‌خواهید این عضو زنجیره پراکسی را حذف کنید؟</string>
<string name="confirm_delete_routing_rule">آیا مطمئن هستید که می‌خواهید این قانون مسیریابی را حذف کنید؟</string>
<string name="confirm_delete_subscription_group">آیا مطمئن هستید که می‌خواهید این گروه اشتراک را حذف کنید؟</string>
<string name="confirm_delete_asset_file">آیا مطمئن هستید که می‌خواهید فایل دارایی \"%1$s\" را حذف کنید؟</string>
<string name="confirm_delete_asset_file">آیا مطمئن هستید که می‌خواهید این فایل دارایی را حذف کنید؟</string>
<string name="confirm_delete_asset_source">آیا مطمئن هستید که می‌خواهید این منبع دارایی را حذف کنید؟</string>
<string name="server_lab_remarks">ملاحظات</string>
<string name="server_lab_address">نشانی</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved (اختیاری، جدا شده با کاما)</string>
<string name="server_lab_local_address">آدرس محلی (IPv4/IPv6 اختیاری، جدا شده با کاما)</string>
<string name="server_lab_local_mtu">MTU (اختیاری، پیش‌فرض 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیش‌فرض هسته 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیش‌فرض هسته 50)</string>
<string name="server_lab_remote_dns">remoteDNS (اختیاری، آدرس‌های IPv4/IPv6 جدا شده با کاما، یا "local" برای استفاده از DNS دستگاه)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیش‌فرض 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیش‌فرض 50 میلی‌ثانیه)</string>
<string name="toast_success">موفقیت‌آمیز</string>
<string name="toast_failure">شکست!</string>
<string name="toast_none_data">داده‌ای یافت نشد</string>
@@ -172,8 +176,11 @@
<string name="title_vpn_settings">تنظیمات VPN</string>
<string name="title_pref_per_app_proxy">پروکسی به تفکیک برنامه</string>
<string name="summary_pref_per_app_proxy">حالت عادی: برنامه‌های انتخاب‌شده از پروکسی استفاده می‌کنند و برنامه‌های انتخاب‌نشده مستقیماً متصل می‌شوند؛\nحالت دور زدن: برنامه‌های انتخاب‌شده مستقیماً متصل می‌شوند و برنامه‌های انتخاب‌نشده از پروکسی استفاده می‌کنند.\nگزینهٔ انتخاب خودکار برنامه‌هایی که باید از پروکسی استفاده کنند در منو قرار دارد.</string>
<string name="title_pref_is_booted">اتصال خودکار هنگام راه اندازی</string>
<string name="summary_pref_is_booted">هنگام راه اندازی به طور خودکار به سرور انتخابی متصل می شود که ممکن است ناموفق باشد.</string>
<string name="title_pref_is_booted">اتصال پس از راه‌اندازی دستگاه</string>
<string name="summary_pref_is_booted">پس از راه‌اندازی دستگاه، به‌طور خودکار به سرور انتخاب‌شده متصل شود. ممکن است Android اجرای پس‌زمینه را محدود کند.</string>
<string name="title_system_vpn_settings">تنظیمات VPN سیستم</string>
<string name="summary_system_vpn_settings">تنظیمات VPN در Android را برای پیکربندی VPN همیشه‌روشن باز کنید، اگر دستگاه از آن پشتیبانی می‌کند.</string>
<string name="toast_system_vpn_settings_unavailable">تنظیمات VPN سیستم در دسترس نیست.</string>
<string name="subscription_updater_job_tips">این کار در پس‌زمینه اجرا می‌شود و پیشرفت آن در اعلان نمایش داده خواهد شد.</string>
@@ -348,8 +355,14 @@
<string name="title_del_config_count">حذف %d کانفیگ</string>
<string name="title_import_config_count">وارد کردن %d کانفیگ</string>
<string name="title_export_config_count">صادر کردن %d کانفیگ</string>
<string name="title_update_config_count">آپدیت کردن %d کانفیگ</string>
<string name="title_update_asset_count">%d فایل منبع بروزرسانی شد</string>
<plurals name="title_update_config_count">
<item quantity="one">%1$d کانفیگ به‌روزرسانی شد</item>
<item quantity="other">%1$d کانفیگ به‌روزرسانی شد</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">%1$d فایل منبع به‌روزرسانی شد</item>
<item quantity="other">%1$d فایل منبع به‌روزرسانی شد</item>
</plurals>
<string name="title_update_subscription_result">بروزرسانی %1$d کانفیگ انجام شد (%2$d موفق، %3$d شکست خورده، %4$d نادیده گرفته شده)</string>
<string name="title_update_subscription_no_subscription">هیچ اشتراکی وجود ندارد</string>
<string name="toast_server_not_found_in_group">سرور انتخاب‌شده در گروه فعلی پیدا نشد</string>
+81 -64
View File
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">Потяните вниз для обновления!</string>
<!-- Notifications -->
<string name="notification_channel_service">Служба v2rayNG</string>
<string name="notification_channel_subscription_updates">Обновления подписок</string>
<string name="notification_channel_connection_checks">Проверки соединения</string>
<string name="notification_action_stop_v2ray">Остановить</string>
<string name="toast_permission_denied">Разрешение не получено</string>
<string name="toast_permission_denied_notification">Разрешение на отображение уведомлений не получено</string>
@@ -46,11 +49,11 @@
<string name="confirm_delete_duplicate_profiles">Удалить все отображаемые дубликаты профилей?</string>
<string name="confirm_delete_invalid_profiles">Выполните проверку перед удалением! Удалить все отображаемые нерабочие профили?</string>
<string name="confirm_delete_profile">Удалить этот профиль?</string>
<string name="confirm_delete_policy_group">Удалить эту политику группы?</string>
<string name="confirm_delete_policy_group">Удалить эту группу политик?</string>
<string name="confirm_delete_proxy_chain_member">Удалить этого участника цепочки прокси?</string>
<string name="confirm_delete_routing_rule">Удалить это правило маршрутизации?</string>
<string name="confirm_delete_subscription_group">Удалить эту подписку?</string>
<string name="confirm_delete_asset_file">Удалить файл ресурсов «%1$s»?</string>
<string name="confirm_delete_asset_file">Удалить этот файл ресурсов?</string>
<string name="confirm_delete_asset_source">Удалить этот источник ресурсов?</string>
<string name="server_lab_remarks">Название</string>
<string name="server_lab_address">Адрес</string>
@@ -81,7 +84,7 @@
<string name="server_lab_stream_security" translatable="false">TLS</string>
<string name="server_lab_stream_fingerprint">Отпечаток</string>
<string name="server_lab_stream_alpn" translatable="false">ALPN</string>
<string name="server_lab_allow_insecure">Пропускать проверку сертификата (allowInsecure)</string>
<string name="server_lab_allow_insecure">Не проверять сертификат (allowInsecure)</string>
<string name="server_lab_sni" translatable="false">SNI</string>
<string name="server_lab_address3">Адрес</string>
<string name="server_lab_port3">Порт</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved (необязательно, через запятую)</string>
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
<string name="server_lab_remote_dns">remoteDNS (необязательно, IPv4/IPv6-адреса через запятую, либо "local" для использования локального DNS)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (необязательно, по умолчанию 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50)</string>
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50 мс)</string>
<string name="toast_success">Успешно</string>
<string name="toast_failure">Ошибка</string>
<string name="toast_none_data">Ничего нет</string>
@@ -116,12 +120,12 @@
<string name="toast_invalid_url">Неправильный URL</string>
<string name="toast_insecure_url_protocol">Не используйте небезопасный HTTP-протокол в адресе подписки</string>
<string name="server_lab_need_inbound">Убедитесь, что входящий порт соответствует настройкам</string>
<string name="toast_malformed_json">Некорректная конфигурация.</string>
<string name="toast_malformed_json">Профиль повреждён</string>
<string name="server_lab_request_host6">Узел (SNI) (необязательно)</string>
<string name="toast_action_not_allowed">Это действие запрещено</string>
<string name="server_obfs_password">Пароль obfs</string>
<string name="server_lab_port_hop">Смена портов (переопределяет порт)</string>
<string name="server_lab_port_hop_interval">Интервал смены портов (секунды)</string>
<string name="server_lab_port_hop_interval">Интервал смены портов (сек.)</string>
<string name="server_lab_bandwidth_down">Входящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_bandwidth_up">Исходящая пропускная способность (допускаются: k/m/g/t)</string>
<string name="server_lab_xhttp_mode">Режим XHTTP</string>
@@ -130,8 +134,8 @@
<string name="server_lab_ech_config_list" translatable="false">echConfigList</string>
<string name="server_lab_verify_peer_cert_by_name">Проверять сертификат узла по имени</string>
<string name="server_lab_pinned_ca256">Отпечаток сертификата (SHA-256)</string>
<string name="server_lab_browser_dialer">Включить Browser Dialer</string>
<string name="server_lab_browser_dialer_tip">Поддерживаются только исходящие соединения XHTTP (packet-up) и WS; настройки, связанные с TLS, могут быть проигнорированы или конфликтовать</string>
<string name="server_lab_browser_dialer">Использовать Browser Dialer</string>
<string name="server_lab_browser_dialer_tip">Поддерживаются только исходящие соединения XHTTP (packet-up) и WS; настройки, связанные с TLS, могут быть проигнорированы или конфликтовать.</string>
<string name="toast_allow_insecure_deprecated">Этот узел использует незашифрованное соединение. Ваши данные могут быть перехвачены и просмотрены государственными структурами через промежуточное сетевое оборудование.\nВ целях безопасности такие узлы больше не поддерживаются ядром Xray версии 26.2.6+.\nДля исправления: включите TLS (или другое шифрование) на своём сервере или используйте отпечаток сертификата (pinnedCA256).\nЕсли вы используете платный сервис, обратитесь в поддержку для обновления. Если провайдер отказывается, рекомендуется сменить его на более безопасный.\nПодробнее: https://github.com/2dust/v2rayN/discussions/9460</string>
<string name="pinned_ca256_action_fetch">Получить отпечаток сертификата</string>
<string name="toast_fetch_cert_sha256_success">Отпечаток сертификата успешно получен</string>
@@ -156,13 +160,13 @@
<string name="menu_item_select_all">Выбрать все</string>
<string name="menu_item_invert_selection">Инвертировать выбор</string>
<string name="msg_enter_keywords">Введите ключевое слово</string>
<string name="switch_bypass_apps_mode">Режим обхода</string>
<string name="switch_bypass_apps_mode">Обход</string>
<string name="menu_item_select_proxy_app">Автовыбор проксируемых приложений</string>
<string name="msg_downloading_content">Загрузка данных</string>
<string name="menu_item_export_proxy_app">Экспорт в буфер обмена</string>
<string name="menu_item_import_proxy_app">Импорт из буфера обмена</string>
<string name="per_app_proxy_settings">Выбор приложений</string>
<string name="per_app_proxy_settings_enable">Использовать выбор приложений</string>
<string name="per_app_proxy_settings_enable">Использовать выбор</string>
<string name="app_picker_unknown_app">Неизвестное приложение (неопознанный UID)</string>
<!-- Preferences -->
@@ -171,21 +175,24 @@
<string name="title_core_settings">Настройки ядра</string>
<string name="title_vpn_settings">Настройки VPN</string>
<string name="title_pref_per_app_proxy">Прокси для выбранных приложений</string>
<string name="summary_pref_per_app_proxy">Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nРежим обхода: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nВ меню можно автоматически выбрать приложения, которые должны использовать прокси.</string>
<string name="title_pref_is_booted">Автоподключение при запуске</string>
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным)</string>
<string name="summary_pref_per_app_proxy">Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nОбход: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nВ меню можно автоматически выбрать приложения, которые должны использовать прокси.</string>
<string name="title_pref_is_booted">Автоподключение при загрузке устройства</string>
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу после загрузки устройства. Android может ограничивать запуск в фоне.</string>
<string name="title_system_vpn_settings">Системные настройки VPN</string>
<string name="summary_system_vpn_settings">Открыть настройки VPN в Android для включения постоянного VPN-подключения, если устройство его поддерживает</string>
<string name="toast_system_vpn_settings_unavailable">Системные настройки VPN недоступны.</string>
<string name="subscription_updater_job_tips">Задача будет выполняться в фоновом режиме, информация о ходе выполнения будет отображаться в уведомлении.</string>
<string name="title_pref_auto_test_after_update_subscription">Автопроверка после обновления подписки</string>
<string name="summary_pref_auto_test_after_update_subscription">Автоматическая проверка занимает много времени. Если параметр включён, задача выполняется в фоновом режиме; иначе — на текущей странице.</string>
<string name="summary_pref_auto_test_after_update_subscription">Автоматическая проверка занимает много времени. Если включено, задача выполняется в фоновом режиме; иначе — для текущей вкладки.</string>
<string name="title_pref_auto_remove_invalid_after_test">Автоудаление нерабочих профилей после проверки</string>
<string name="summary_pref_auto_remove_invalid_after_test">Результаты проверки могут быть неточными, восстановление удалённых профилей невозможно</string>
<string name="title_pref_auto_sort_after_test">Автосортировка профилей</string>
<string name="summary_pref_auto_sort_after_test">Автоматическая сортировка профилей после проверки (результаты проверки могут быть неточными)</string>
<string name="title_mux_settings">Настройки мультиплексирования (Mux)</string>
<string name="title_pref_mux_enabled">Использовать мультиплексирование (Mux)</string>
<string name="title_mux_settings">Настройки мультиплексирования</string>
<string name="title_pref_mux_enabled">Использовать мультиплексирование</string>
<string name="summary_pref_mux_enabled">Быстрее, но это может привести к нестабильному соединению.\nНиже можно настроить обработку TCP, UDP и QUIC.</string>
<string name="title_pref_mux_concurrency">TCP-соединения (диапазон от -1 до 1024)</string>
<string name="title_pref_mux_xudp_concurrency">XUDP-соединения (диапазон от -1 до 1024)</string>
@@ -199,24 +206,24 @@
<string name="title_pref_speed_enabled">Показывать скорость</string>
<string name="summary_pref_speed_enabled">Показывать текущую скорость в уведомлении.\nЗначок указывает, какой трафик преобладает: через прокси или напрямую.</string>
<string name="title_pref_sniffing_enabled">Определять домены в трафике (Sniffing)</string>
<string name="summary_pref_sniffing_enabled">Определять домены назначения в трафике (по умолчанию включено).</string>
<string name="title_pref_route_only_enabled">Включить routeOnly</string>
<string name="summary_pref_route_only_enabled">Использовать доменное имя только для маршрутизации и сохранять целевой адрес в виде IP.</string>
<string name="title_pref_sniffing_enabled">Анализировать трафик (sniffing)</string>
<string name="summary_pref_sniffing_enabled">Определять домены назначения в трафике (по умолчанию включено)</string>
<string name="title_pref_route_only_enabled">Домен только для маршрутизации (routeOnly)</string>
<string name="summary_pref_route_only_enabled">Использовать доменное имя только для маршрутизации и сохранять целевой адрес в виде IP</string>
<string name="title_pref_local_dns_enabled">Использовать локальный DNS</string>
<string name="title_pref_local_dns_enabled">Использовать локальный сервер DNS</string>
<string name="summary_pref_local_dns_enabled">Обслуживание выполняется DNS-модулем ядра (в настройках маршрутизации рекомендуется выбрать режим «Все, кроме LAN и Китая»)</string>
<string name="title_pref_fake_dns_enabled">Использовать виртуальный DNS (FakeDNS)</string>
<string name="summary_pref_fake_dns_enabled">FakeDNS возвращает виртуальные IP-адреса при разрешении доменов (быстрее, но может не работать с некоторыми приложениями).</string>
<string name="title_pref_fake_dns_enabled">Использовать виртуальный сервер DNS (FakeDNS)</string>
<string name="summary_pref_fake_dns_enabled">FakeDNS возвращает виртуальные IP-адреса при определении доменов (быстрее, но может не работать с некоторыми приложениями)</string>
<string name="title_pref_ipv6_enabled">Включить IPv6</string>
<string name="title_pref_ipv6_enabled">Использовать IPv6</string>
<string name="summary_pref_ipv6_enabled">Добавить IPv6-адрес и маршруты в VPN-интерфейс</string>
<string name="title_pref_prefer_ipv6">Предпочитать IPv6</string>
<string name="summary_pref_prefer_ipv6">Предпочитать IPv6-адреса при определении доменных имён</string>
<string name="title_pref_remote_dns">Удалённый DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
<string name="title_pref_remote_dns">Удалённый сервер DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
<string name="summary_pref_remote_dns" translatable="false">DNS</string>
<string name="title_pref_vpn_dns">VPN DNS (только IPv4/IPv6)</string>
@@ -233,15 +240,15 @@
<string name="title_pref_dns_hosts">Сопоставления DNS (формат: domain:address,…)</string>
<string name="summary_pref_dns_hosts">домен:адрес,…</string>
<string name="title_pref_delay_test_url">Сервис проверки задержки</string>
<string name="title_pref_delay_test_url">Сервис проверки реальной задержки</string>
<string name="summary_pref_delay_test_url" translatable="false">URL</string>
<string name="title_pref_real_ping_concurrency">Параллельная проверка задержки</string>
<string name="title_pref_real_ping_concurrency">Параллельные тесты реальной задержки</string>
<string name="title_pref_ip_api_url">Сервис проверки текущего соединения</string>
<string name="summary_pref_ip_api_url" translatable="false">URL</string>
<string name="title_pref_proxy_sharing_enabled">Разрешать соединения из LAN</string>
<string name="summary_pref_proxy_sharing_enabled">Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения.</string>
<string name="summary_pref_proxy_sharing_enabled">Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения</string>
<string name="toast_warning_pref_proxysharing_short">Доступ из LAN разрешён, убедитесь, что вы находитесь в доверенной сети</string>
<string name="title_pref_socks_port">Порт локального прокси</string>
@@ -251,27 +258,27 @@
<string name="summary_pref_socks_enable_udp">Аутентификация SOCKS5 не полностью безопасна при использовании UDP</string>
<string name="summary_pref_socks_port">Порт локального прокси</string>
<string name="title_pref_dynamic_socks_port">Динамически менять порт локального прокси</string>
<string name="summary_pref_dynamic_socks_port">Использовать случайный порт локального прокси для каждого входящего подключения (inbound)</string>
<string name="summary_pref_dynamic_socks_port">Использовать случайный порт локального прокси для каждого входящего подключения</string>
<string name="title_pref_socks_username">Пользователь локального прокси (необязательно)</string>
<string name="summary_pref_socks_username">Имя пользователя</string>
<string name="title_pref_socks_password">Пароль локального прокси (необязательно)</string>
<string name="summary_pref_socks_password">Пароль</string>
<string name="title_pref_local_dns_port">Порт локального DNS</string>
<string name="summary_pref_local_dns_port">Порт локального DNS</string>
<string name="title_pref_local_dns_port">Порт локального сервера DNS</string>
<string name="summary_pref_local_dns_port">Порт локального сервера DNS</string>
<string name="title_pref_confirm_remove">Подтверждать удаление профиля</string>
<string name="summary_pref_confirm_remove">Обязательное подтверждение удаления профиля</string>
<string name="title_pref_append_http_proxy">Добавить HTTP-прокси в VPN</string>
<string name="summary_pref_append_http_proxy">Браузеры и поддерживаемые приложения смогут использовать HTTP-прокси напрямую, минуя виртуальный сетевой адаптер (Android 10+).</string>
<string name="title_pref_append_http_proxy">Дополнительный HTTP-прокси</string>
<string name="summary_pref_append_http_proxy">Браузеры и поддерживаемые приложения смогут использовать HTTP-прокси напрямую, минуя виртуальный сетевой адаптер (Android 10+)</string>
<string name="title_pref_double_column_display">Профили в два столбца</string>
<string name="summary_pref_double_column_display">Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране.</string>
<string name="summary_pref_double_column_display">Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране</string>
<string name="title_pref_group_all_display">Показывать вкладку «Все»</string>
<string name="summary_pref_group_all_display">Объединять профили из всех групп подписок на одной вкладке.</string>
<string name="summary_pref_group_all_display">Вкладка, объединяющая профили из всех групп</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string>
@@ -293,24 +300,24 @@
<string name="title_pref_auto_update_interval">Интервал автообновления (минут, не менее 15)</string>
<string name="title_core_loglevel">Подробность ведения журнала</string>
<string name="title_outbound_domain_resolve_method">Разрешение доменов исходящих подключений (outbound)</string>
<string name="title_outbound_domain_resolve_method">Определение доменов исходящих подключений</string>
<string name="title_mode">Режим</string>
<string name="title_mode_help">Нажмите для получения дополнительной информации</string>
<string name="title_language">Язык</string>
<string name="title_ui_settings">Настройки интерфейса</string>
<string name="title_pref_ui_mode_night">Тема интерфейса</string>
<string name="title_pref_dynamic_color">Включить динамические цвета</string>
<string name="title_pref_dynamic_color">Динамические цвета</string>
<string name="summary_pref_dynamic_color">Работает только на Android 12+</string>
<string name="title_pref_use_hev_tunnel">Использовать Hev TUN</string>
<string name="summary_pref_use_hev_tunnel">Если включено, TUN будет использовать hev-socks5-tunnel; иначе будет использован xray-core</string>
<string name="title_pref_hev_tunnel_loglevel">Уровень журналирования Hev TUN</string>
<string name="title_pref_hev_tunnel_rw_timeout">Тайм-аут чтения/записи Hev TUN (секунды; значения TCP/UDP по умолчанию: 300/60)</string>
<string name="title_pref_hev_tunnel_rw_timeout">Время ожидания чтения/записи Hev TUN (секунды; значения TCP/UDP по умолчанию: 300/60)</string>
<string name="title_mode_settings">Настройки режима</string>
<string name="title_root_mode_enabled">Использовать root-режим</string>
<string name="summary_root_mode_enabled">Использовать root-доступ для низкоуровневого прозрачного проксирования. Root-режим управляет сетевым трафиком напрямую вместо службы VPN Android. Пока он включён, обычный режим, маршрутизация отдельных приложений и связанные настройки VPN не действуют.</string>
<string name="toast_root_required">Для этой функции требуется доступ с правами root</string>
<string name="title_root_lan_sharing">Доступ к VPN для устройств LAN/точки доступа (root)</string>
<string name="summary_root_lan_sharing">Направлять через VPN трафик устройств, подключённых к точке доступа Wi-Fi или по USB.</string>
<string name="summary_root_lan_sharing">Направлять через VPN трафик устройств, подключённых к точке доступа Wi-Fi или по USB</string>
<string name="title_logcat" translatable="false">Logcat</string>
<string name="logcat_copy">Копировать</string>
@@ -320,24 +327,24 @@
<string name="title_del_all_config">Удалить профили</string>
<string name="title_del_duplicate_config">Удалить дубликаты профилей</string>
<string name="title_del_invalid_config">Удалить нерабочие профили</string>
<string name="title_export_all">Экспортировать профили в буфер обмена</string>
<string name="title_sub_setting">Подписки</string>
<string name="title_export_all">Экспорт профилей в буфер обмена</string>
<string name="title_sub_setting">Группы</string>
<string name="sub_setting_remarks">Название</string>
<string name="sub_setting_url">URL (необязательно)</string>
<string name="sub_setting_user_agent" translatable="false">User Agent</string>
<string name="sub_setting_request_headers">Заголовки запроса (формат JSON)</string>
<string name="sub_setting_filter">Фильтр названий (регулярное выражение)</string>
<string name="sub_setting_enable">Включить обновление</string>
<string name="sub_setting_enable">Использовать обновление</string>
<string name="sub_auto_update">Использовать автообновление</string>
<string name="sub_allow_insecure_url">Разрешать незащищённые HTTP-адреса</string>
<string name="sub_setting_pre_profile">Входной прокси</string>
<string name="sub_setting_next_profile">Выходной прокси</string>
<string name="sub_setting_pre_profile_tip">Убедитесь, что профиль с таким названием существует и его название уникально</string>
<string name="sub_setting_entry_proxy_tip">Выбранный прокси добавляется перед каждым профилем подписки и становится входным звеном цепочки прокси для этого профиля.</string>
<string name="sub_setting_exit_proxy_tip">Выбранный прокси добавляется после каждого профиля подписки и становится выходным звеном цепочки прокси для этого профиля.</string>
<string name="sub_setting_pre_profile_tip">Убедитесь, что профиль с таким названием существует и его название уникально.</string>
<string name="sub_setting_entry_proxy_tip">Выбранный прокси добавляется перед каждым профилем подписки и становится входным участником цепочки прокси для этого профиля.</string>
<string name="sub_setting_exit_proxy_tip">Выбранный прокси добавляется после каждого профиля подписки и становится выходным участником цепочки прокси для этого профиля.</string>
<string name="toast_invalid_update_interval">Неправильный интервал обновления. Минимум 15 минут.</string>
<string name="title_sub_update">Обновить подписку</string>
<string name="title_ping_all_server">Проверить TCP-задержку (TCPing)</string>
<string name="title_ping_all_server">Проверить TCP-задержку</string>
<string name="title_real_ping_all_server">Проверить реальную задержку</string>
<string name="title_user_asset_setting">Файлы ресурсов</string>
<string name="title_sort_by_test_results">Сортировать по результатам теста</string>
@@ -348,8 +355,18 @@
<string name="title_del_config_count">Удалено профилей: %d</string>
<string name="title_import_config_count">Импортировано профилей: %d</string>
<string name="title_export_config_count">Экспортировано профилей: %d</string>
<string name="title_update_config_count">Обновлено профилей: %d</string>
<string name="title_update_asset_count">Обновлено файлов ресурсов: %d</string>
<plurals name="title_update_config_count">
<item quantity="one">Обновлён %1$d профиль</item>
<item quantity="few">Обновлено %1$d профиля</item>
<item quantity="many">Обновлено %1$d профилей</item>
<item quantity="other">Обновлено %1$d профиля</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">Обновлён %1$d файл ресурсов</item>
<item quantity="few">Обновлено %1$d файла ресурсов</item>
<item quantity="many">Обновлено %1$d файлов ресурсов</item>
<item quantity="other">Обновлено %1$d файла ресурсов</item>
</plurals>
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибки: %3$d, пропущено: %4$d)</string>
<string name="title_update_subscription_no_subscription">Нет подписок</string>
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
@@ -361,7 +378,7 @@
<!-- RoutingSettingActivity -->
<string name="routing_settings_domain_strategy">Доменная стратегия</string>
<string name="routing_settings_title">Маршрутизация</string>
<string name="routing_settings_tips">Правило применяется, только если одновременно выполняются все заданные условия сопоставления (AND). Правила проверяются сверху вниз; применяется первое совпавшее правило.</string>
<string name="routing_settings_tips">Правило применяется, только если одновременно выполняются все заданные условия сопоставления (логический оператор «И»). Правила проверяются сверху вниз; применяется первое совпавшее правило.</string>
<string name="routing_settings_comma_tip">Разделяйте несколько значений запятыми (,).</string>
<string name="routing_settings_save">Сохранить</string>
<string name="routing_settings_delete">Очистить</string>
@@ -375,7 +392,7 @@
<string name="routing_settings_locked">Постоянное (сохранится при импорте правил)</string>
<string name="routing_settings_domain" translatable="false">domain</string>
<string name="routing_settings_ip" translatable="false">ip</string>
<string name="routing_settings_process">process (имя пакета — поддерживается только при использовании Xray TUN, включённом routeOnly и Android 10+)</string>
<string name="routing_settings_process">process (имя пакета — поддерживается только при использовании Xray TUN, включённой функции «Домен только для маршрутизации (routeOnly)» и Android 10+)</string>
<string name="routing_settings_process_select">Выбор названия пакета</string>
<string name="routing_settings_port" translatable="false">port</string>
<string name="routing_settings_protocol" translatable="false">protocol</string>
@@ -398,13 +415,13 @@
<string name="import_subscription_success">Подписка импортирована</string>
<string name="import_subscription_failure">Невозможно импортировать подписку</string>
<string name="title_fragment_settings">Настройки фрагментирования (Fragment)</string>
<string name="title_fragment_settings">Настройки фрагментирования</string>
<string name="title_pref_fragment_packets">Фрагментирование пакетов</string>
<string name="title_pref_fragment_length">Длина фрагмента (от — до)</string>
<string name="title_pref_fragment_interval">Интервал фрагментов (от — до)</string>
<string name="title_pref_fragment_enabled">Использовать фрагментирование</string>
<string name="title_pref_fragment_maxsplit">Максимальное число фрагментов</string>
<string name="title_observatory_settings">Настройки мониторинга соединений (Observatory)</string>
<string name="title_observatory_settings">Настройки контроля соединений</string>
<string name="title_pref_observatory_least_ping_interval">Интервал leastPing</string>
<string name="title_pref_observatory_least_load_interval">Интервал leastLoad</string>
<string name="title_pref_observatory_least_load_method">HTTP-метод leastLoad</string>
@@ -423,8 +440,8 @@
<string name="title_policy_group_type">Тип политики группы</string>
<string name="title_policy_group_subscription_id">Из группы подписки</string>
<string name="title_policy_group_subscription_filter">Фильтр примечаний (регулярное выражение)</string>
<string name="title_policy_group_test_outbounds">Тестировать исходящие подключения (outbounds)</string>
<string name="title_policy_group_fallback">Резервное исходящее подключение (outbound, необязательно)</string>
<string name="title_policy_group_test_outbounds">Тестировать исходящие подключения</string>
<string name="title_policy_group_fallback">Резервное исходящее подключение (необязательно)</string>
<string name="server_proxy_chain_members">Участники цепочки прокси</string>
<string name="server_proxy_chain_pick_members">Нажмите здесь, чтобы выбрать участника</string>
@@ -434,8 +451,8 @@
<string name="server_proxy_chain_members_invalid">Недопустимые участники цепочки: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Резервное копирование и восстановление</string>
<string name="title_configuration_backup">Создать резервную копию</string>
<string name="title_configuration_backup_restore">Резервирование и восстановление</string>
<string name="title_configuration_backup">Резервирование конфигурации</string>
<string name="title_configuration_restore">Восстановление конфигурации</string>
<string name="title_profile_storage_cleanup">Исправить хранилище профилей</string>
<string name="summary_profile_storage_cleanup">Удалить данные неиспользуемых профилей</string>
@@ -458,7 +475,7 @@
<string name="action_close">Закрыть</string>
<string name="share_subscription_qrcode">QR-код</string>
<string name="share_subscription_clipboard">Экспорт в буфер обмена</string>
<string name="share_subscription_clipboard">Копировать ссылку</string>
<string-array name="mode_entries">
<item>VPN</item>
@@ -479,7 +496,7 @@
<string name="routing_preset_china_whitelist">Белый список Китая</string>
<string name="routing_preset_china_blacklist">Чёрный список Китая</string>
<string name="routing_preset_global">Глобальная</string>
<string name="routing_preset_global">Общие</string>
<string name="routing_preset_iran_whitelist">Белый список Ирана</string>
<string name="routing_preset_russia_whitelist">Белый список России</string>
@@ -490,9 +507,9 @@
</string-array>
<string-array name="outbound_domain_resolve_method">
<item>Не разрешать</item>
<item>Разрешать и добавлять в DNS Hosts</item>
<item>Разрешать и заменять домен</item>
<item>Не определять</item>
<item>Определять и добавлять к узлам DNS</item>
<item>Определять и заменять домен</item>
</string-array>
<string-array name="policy_group_type">
@@ -521,7 +538,7 @@
<string name="acc_search">Поиск</string>
<string name="acc_add">Добавить профиль</string>
<string name="acc_more">Дополнительные параметры</string>
<string name="acc_selected_server">Selected</string>
<string name="acc_selected_server">Выбран</string>
<string name="acc_share_subscription">Поделиться ссылкой на подписку</string>
<string name="acc_share_log">Поделиться журналом</string>
<string name="acc_remove">Удалить участника цепочки прокси</string>
@@ -529,9 +546,9 @@
<string name="acc_start_scanner">Запустить сканер QR-кодов</string>
<string name="acc_stop_scanner">Остановить сканер QR-кодов</string>
<string name="acc_turn_torch_on">Включить фонарик</string>
<string name="acc_turn_torch_off">Выключить фонарик</string>
<string name="acc_turn_torch_off">Отключить фонарик</string>
<string name="acc_select_image">Выбрать изображение для сканирования</string>
<string name="acc_locked">Правило сохраняется при импорте предустановок</string>
<string name="acc_locked">Правило сохраняется при импорте набора</string>
<string name="acc_open_menu">Открыть меню навигации</string>
<string name="acc_edit">Изменить</string>
<string name="acc_delete">Удалить</string>
+18 -7
View File
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">Kéo xuống để làm mới!</string>
<!-- Notifications -->
<string name="notification_channel_service">Dịch vụ v2rayNG</string>
<string name="notification_channel_subscription_updates">Cập nhật gói đăng ký</string>
<string name="notification_channel_connection_checks">Kiểm tra kết nối</string>
<string name="notification_action_stop_v2ray">Ngắt kết nối v2rayNG</string>
<string name="toast_permission_denied">Vui lòng cấp các quyền cần thiết cho v2rayNG. Bạn có thể đã từ chối quyền truy cập máy ảnh hoặc bộ nhớ.</string>
<string name="toast_permission_denied_notification">Không thể nhận quyền thông báo</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">Bạn có chắc muốn xóa thành viên này khỏi chuỗi proxy không?</string>
<string name="confirm_delete_routing_rule">Bạn có chắc muốn xóa quy tắc định tuyến này không?</string>
<string name="confirm_delete_subscription_group">Bạn có chắc muốn xóa nhóm đăng ký này không?</string>
<string name="confirm_delete_asset_file">Bạn có chắc muốn xóa tệp tài nguyên \"%1$s\" không?</string>
<string name="confirm_delete_asset_file">Bạn có chắc muốn xóa tệp tài nguyên này không?</string>
<string name="confirm_delete_asset_source">Bạn có chắc muốn xóa nguồn tài nguyên này không?</string>
<string name="server_lab_remarks">Tên cấu hình</string>
<string name="server_lab_address">Địa chỉ</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved (không bắt buộc, phân cách bằng dấu phẩy)</string>
<string name="server_lab_local_address">Địa chỉ cục bộ (IPv4/IPv6 không bắt buộc, phân cách bằng dấu phẩy)</string>
<string name="server_lab_local_mtu">MTU (không bắt buộc, mặc định 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (không bắt buộc, mặc định của core là 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (không bắt buộc, mặc định của core là 50)</string>
<string name="server_lab_remote_dns">remoteDNS (tùy chọn, các địa chỉ IPv4/IPv6 cách nhau bằng dấu phẩy, hoặc "local" để dùng DNS của thiết bị)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (không bắt buộc, mặc định 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (không bắt buộc, mặc định 50 ms)</string>
<string name="toast_success">Thành công!</string>
<string name="toast_failure">Đã xảy ra lỗi, vui lòng thử lại!</string>
<string name="toast_none_data">Không có gì ở đây!</string>
@@ -172,8 +176,11 @@
<string name="title_vpn_settings">Cài đặt VPN</string>
<string name="title_pref_per_app_proxy">Proxy theo từng ứng dụng</string>
<string name="summary_pref_per_app_proxy">Thông thường: ứng dụng đã chọn dùng proxy, ứng dụng chưa chọn kết nối trực tiếp;\nChế độ bỏ qua: ứng dụng đã chọn kết nối trực tiếp, ứng dụng chưa chọn dùng proxy.\nTrong trình đơn có tùy chọn tự động chọn các ứng dụng sẽ dùng proxy.</string>
<string name="title_pref_is_booted">Tự động kết nối khi khởi động</string>
<string name="summary_pref_is_booted">Tự động kết nối với máy chủ đã chọn khi khởi động; thao tác này có thể không thành công</string>
<string name="title_pref_is_booted">Kết nối sau khi thiết bị khởi động</string>
<string name="summary_pref_is_booted">Tự động kết nối với máy chủ đã chọn sau khi thiết bị khởi động. Android có thể hạn chế khởi chạy trong nền.</string>
<string name="title_system_vpn_settings">Cài đặt VPN hệ thống</string>
<string name="summary_system_vpn_settings">Mở cài đặt VPN của Android để cấu hình VPN luôn bật, nếu thiết bị này hỗ trợ.</string>
<string name="toast_system_vpn_settings_unavailable">Không có cài đặt VPN hệ thống.</string>
<string name="subscription_updater_job_tips">Tác vụ sẽ chạy trong nền và thông tin tiến độ sẽ hiển thị trong thông báo.</string>
@@ -348,8 +355,12 @@
<string name="title_del_config_count">Xóa %d cấu hình</string>
<string name="title_import_config_count">Nhập %d cấu hình</string>
<string name="title_export_config_count">Xuất %d cấu hình</string>
<string name="title_update_config_count">Cập nhật %d cấu hình</string>
<string name="title_update_asset_count">Đã cập nhật %d tệp tài nguyên</string>
<plurals name="title_update_config_count">
<item quantity="other">Đã cập nhật %1$d cấu hình</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="other">Đã cập nhật %1$d tệp tài nguyên</item>
</plurals>
<string name="title_update_subscription_result">Đã cập nhật %1$d cấu hình (%2$d thành công, %3$d thất bại, %4$d bỏ qua)</string>
<string name="title_update_subscription_no_subscription">Không có đăng ký</string>
<string name="toast_server_not_found_in_group">Không tìm thấy máy chủ đã chọn trong nhóm hiện tại</string>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">请下拉刷新!</string>
<!-- Notifications -->
<string name="notification_channel_service">v2rayNG 服务</string>
<string name="notification_channel_subscription_updates">订阅更新</string>
<string name="notification_channel_connection_checks">连接检查</string>
<string name="notification_action_stop_v2ray">停止</string>
<string name="toast_permission_denied">无法取得权限</string>
<string name="toast_permission_denied_notification">无法取得通知权限</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">确定要删除此代理链成员吗?</string>
<string name="confirm_delete_routing_rule">确定要删除此路由规则吗?</string>
<string name="confirm_delete_subscription_group">确定要删除此订阅组吗?</string>
<string name="confirm_delete_asset_file">确定要删除资源文件“%1$s”吗?</string>
<string name="confirm_delete_asset_file">确定要删除此资源文件吗?</string>
<string name="confirm_delete_asset_source">确定要删除此资源来源吗?</string>
<string name="server_lab_remarks">别名 (remarks)</string>
<string name="server_lab_address">地址 (address)</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved (可选,逗号隔开)</string>
<string name="server_lab_local_address">本地地址 (可选 IPv4/IPv6,逗号隔开)</string>
<string name="server_lab_local_mtu">MTU(可选,默认 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU(可选,核心默认 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI(可选,核心默认 50)</string>
<string name="server_lab_remote_dns">remoteDNS(可选,多个 IPv4/IPv6 地址用逗号分隔,或填写 "local" 使用设备本地 DNS)</string>
<string name="server_lab_kcp_mtu">mKCP MTU(可选,默认 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI(可选,默认 50 毫秒)</string>
<string name="toast_success">成功</string>
<string name="toast_failure">失败</string>
<string name="toast_none_data">没有数据</string>
@@ -172,8 +176,11 @@
<string name="title_vpn_settings">VPN 设置</string>
<string name="title_pref_per_app_proxy">分应用代理</string>
<string name="summary_pref_per_app_proxy">常规:勾选的应用使用代理,未勾选的应用直连;\n绕行模式:勾选的应用直连,未勾选的应用使用代理。\n菜单中可自动选择需要使用代理的应用。</string>
<string name="title_pref_is_booted">开机时自动连接</string>
<string name="summary_pref_is_booted">开机时自动连接选择的服务器,可能会不成功</string>
<string name="title_pref_is_booted">设备启动后自动连接</string>
<string name="summary_pref_is_booted">设备启动后自动连接到所选服务器。Android 可能会限制后台启动。</string>
<string name="title_system_vpn_settings">系统 VPN 设置</string>
<string name="summary_system_vpn_settings">打开 Android VPN 设置,配置始终开启的 VPN(如果此设备支持)。</string>
<string name="toast_system_vpn_settings_unavailable">系统 VPN 设置不可用。</string>
<string name="subscription_updater_job_tips">任务将在后台运行,在通知中会显示进度信息</string>
@@ -348,8 +355,12 @@
<string name="title_del_config_count">删除 %d 个配置</string>
<string name="title_import_config_count">导入 %d 个配置</string>
<string name="title_export_config_count">导出 %d 个配置</string>
<string name="title_update_config_count">更新 %d 个配置</string>
<string name="title_update_asset_count">已更新 %d 个资源文件</string>
<plurals name="title_update_config_count">
<item quantity="other">已更新 %1$d 个配置</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="other">已更新 %1$d 个资源文件</item>
</plurals>
<string name="title_update_subscription_result">更新了 %1$d 个配置(%2$d 个成功,%3$d 个失败,%4$d 个跳过)</string>
<string name="title_update_subscription_no_subscription">无订阅</string>
<string name="toast_server_not_found_in_group">当前分组中未找到选中的服务器</string>
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">請下拉重新整理!</string>
<!-- Notifications -->
<string name="notification_channel_service">v2rayNG 服務</string>
<string name="notification_channel_subscription_updates">訂閱更新</string>
<string name="notification_channel_connection_checks">連線檢查</string>
<string name="notification_action_stop_v2ray">停止</string>
<string name="toast_permission_denied">無法取得此權限</string>
<string name="toast_permission_denied_notification">無法取得此通知權限</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">確定要刪除此代理鏈成員嗎?</string>
<string name="confirm_delete_routing_rule">確定要刪除此路由規則嗎?</string>
<string name="confirm_delete_subscription_group">確定要刪除此訂閱群組嗎?</string>
<string name="confirm_delete_asset_file">確定要刪除資源檔案「%1$s」嗎?</string>
<string name="confirm_delete_asset_file">確定要刪除此資源檔案嗎?</string>
<string name="confirm_delete_asset_source">確定要刪除此資源來源嗎?</string>
<string name="server_lab_remarks">備註</string>
<string name="server_lab_address">位址</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved (可選,逗號隔開)</string>
<string name="server_lab_local_address">本機位址 (可選 IPv4/IPv6,逗號隔開)</string>
<string name="server_lab_local_mtu">MTU(可選,預設 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU(可選,核心預設 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI(可選,核心預設 50)</string>
<string name="server_lab_remote_dns">remoteDNS(選填,多個 IPv4/IPv6 位址以逗號分隔,或輸入 "local" 使用裝置本機 DNS)</string>
<string name="server_lab_kcp_mtu">mKCP MTU(可選,預設 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI(可選,預設 50 毫秒)</string>
<string name="toast_success">成功</string>
<string name="toast_failure">失敗</string>
<string name="toast_none_data">無資料</string>
@@ -172,8 +176,11 @@
<string name="title_vpn_settings">VPN 設定</string>
<string name="title_pref_per_app_proxy">個別應用程式代理</string>
<string name="summary_pref_per_app_proxy">一般:已勾選的應用程式使用代理,未勾選的應用程式直接連線;\n繞行模式:已勾選的應用程式直接連線,未勾選的應用程式使用代理。\n選單中可自動選取需要使用代理的應用程式。</string>
<string name="title_pref_is_booted">開機時自動連線</string>
<string name="summary_pref_is_booted">開機時自動連線選擇的伺服器,可能會不成功</string>
<string name="title_pref_is_booted">裝置開機後自動連線</string>
<string name="summary_pref_is_booted">裝置開機後自動連線至所選伺服器。Android 可能會限制背景啟動。</string>
<string name="title_system_vpn_settings">系統 VPN 設定</string>
<string name="summary_system_vpn_settings">開啟 Android VPN 設定,設定永遠開啟的 VPN(若此裝置支援)。</string>
<string name="toast_system_vpn_settings_unavailable">系統 VPN 設定無法使用。</string>
<string name="subscription_updater_job_tips">任務將在背景執行,通知中會顯示進度資訊。</string>
@@ -348,8 +355,12 @@
<string name="title_del_config_count">刪除 %d 個設定</string>
<string name="title_import_config_count">匯入 %d 個設定</string>
<string name="title_export_config_count">匯出 %d 個設定</string>
<string name="title_update_config_count">更新 %d 個設定</string>
<string name="title_update_asset_count">已更新 %d 個資源檔案</string>
<plurals name="title_update_config_count">
<item quantity="other">已更新 %1$d 個設定</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="other">已更新 %1$d 個資源檔案</item>
</plurals>
<string name="title_update_subscription_result">更新了 %1$d 個設定(%2$d 個成功,%3$d 個失敗,%4$d 個跳過)</string>
<string name="title_update_subscription_no_subscription">無訂閱</string>
<string name="toast_server_not_found_in_group">目前群組中找不到所選伺服器</string>
+20 -7
View File
@@ -14,6 +14,9 @@
<string name="pull_down_to_refresh">Please pull down to refresh!</string>
<!-- Notifications -->
<string name="notification_channel_service">v2rayNG service</string>
<string name="notification_channel_subscription_updates">Subscription updates</string>
<string name="notification_channel_connection_checks">Connection checks</string>
<string name="notification_action_stop_v2ray">Stop</string>
<string name="toast_permission_denied">Permission denied</string>
<string name="toast_permission_denied_notification">Notification permission denied</string>
@@ -50,7 +53,7 @@
<string name="confirm_delete_proxy_chain_member">Are you sure you want to delete this chain member?</string>
<string name="confirm_delete_routing_rule">Are you sure you want to delete this routing rule?</string>
<string name="confirm_delete_subscription_group">Are you sure you want to delete this subscription group?</string>
<string name="confirm_delete_asset_file">Are you sure you want to delete the asset file \"%1$s\"?</string>
<string name="confirm_delete_asset_file">Are you sure you want to delete this asset file?</string>
<string name="confirm_delete_asset_source">Are you sure you want to delete this asset source?</string>
<string name="server_lab_remarks">remarks</string>
<string name="server_lab_address">address</string>
@@ -100,8 +103,9 @@
<string name="server_lab_reserved">Reserved (optional, separated by commas)</string>
<string name="server_lab_local_address">Local address (optional IPv4/IPv6, separated by commas)</string>
<string name="server_lab_local_mtu">MTU (optional, default 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (optional, core default 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (optional, core default 50)</string>
<string name="server_lab_remote_dns">remoteDNS (optional, comma-separated IPv4/IPv6, or "local" to use device DNS)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (optional, default 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (optional, default 50 ms)</string>
<string name="toast_success">Success</string>
<string name="toast_failure">Failure</string>
<string name="toast_none_data">No data available</string>
@@ -172,8 +176,11 @@
<string name="title_vpn_settings">VPN Settings</string>
<string name="title_pref_per_app_proxy">Per-app proxy</string>
<string name="summary_pref_per_app_proxy">General: checked apps use the proxy, and unchecked apps connect directly;\nBypass mode: checked apps connect directly, and unchecked apps use the proxy.\nThe option to automatically select apps that should use the proxy is in the menu.</string>
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string name="title_pref_is_booted">Connect after device boot</string>
<string name="summary_pref_is_booted">Automatically connect to the selected server after the device starts. Android may restrict background starts.</string>
<string name="title_system_vpn_settings">System VPN settings</string>
<string name="summary_system_vpn_settings">Open Android VPN settings to configure Always-on VPN, if supported by this device.</string>
<string name="toast_system_vpn_settings_unavailable">System VPN settings are unavailable.</string>
<string name="subscription_updater_job_tips">The task will run in the background, and progress information will be displayed in the notification.</string>
@@ -348,8 +355,14 @@
<string name="title_del_config_count">Delete %d configs</string>
<string name="title_import_config_count">Import %d configs</string>
<string name="title_export_config_count">Export %d configs</string>
<string name="title_update_config_count">Update %d configs</string>
<string name="title_update_asset_count">Updated %d asset files</string>
<plurals name="title_update_config_count">
<item quantity="one">Updated %1$d config</item>
<item quantity="other">Updated %1$d configs</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">Updated %1$d asset file</item>
<item quantity="other">Updated %1$d asset files</item>
</plurals>
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string name="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
@@ -0,0 +1,114 @@
package com.v2ray.ang.handler
import android.util.Log
import com.tencent.mmkv.MMKV
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.util.JsonUtil
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.reset
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
class SubscriptionIndexTest {
private val mainValues = mutableMapOf<String, String>()
private val subValues = mutableMapOf<String, String>()
@Before
fun prepareStorage() {
for ((storage, values) in listOf(main to mainValues, subs to subValues)) {
reset(storage)
whenever(storage.decodeString(any())).thenAnswer { values[it.getArgument<String>(0)] }
whenever(storage.encode(any<String>(), any<String>())).thenAnswer {
values[it.getArgument(0)] = it.getArgument(1)
true
}
whenever(storage.allKeys()).thenAnswer { values.keys.toTypedArray() }
}
}
@Test
fun duplicateIdsKeepTheirFirstPositionWithoutWritingStorage() {
val stored = """["second","first","second","third","first"]"""
mainValues["SUB_IDS"] = stored
assertEquals(listOf("second", "first", "third"), MmkvManager.decodeSubsList())
assertEquals(stored, mainValues["SUB_IDS"])
verify(main, never()).encode(any<String>(), any<String>())
}
@Test
fun duplicateIndexEntriesProduceOnlyOneSubscriptionRow() {
mainValues["SUB_IDS"] = """["b","a","b","a"]"""
subValues["a"] = JsonUtil.toJson(SubscriptionItem(remarks = "Alpha"))
subValues["b"] = JsonUtil.toJson(SubscriptionItem(remarks = "Beta"))
val rows = MmkvManager.decodeSubscriptions()
assertEquals(listOf("b", "a"), rows.map { it.guid })
assertEquals(listOf("Beta", "Alpha"), rows.map { it.subscription.remarks })
}
@Test
fun repeatedAndBlankNamesDoNotMergeDifferentSubscriptions() {
mainValues["SUB_IDS"] = """["a","b","c","d"]"""
listOf("a" to "Same", "b" to "Same", "c" to "", "d" to " ").forEach { (id, name) ->
subValues[id] = JsonUtil.toJson(SubscriptionItem(remarks = name))
}
assertEquals(listOf("a", "b", "c", "d"), MmkvManager.decodeSubscriptions().map { it.guid })
}
@Test
fun decodedIndexRemainsMutableAndCanBeSavedInANewOrder() {
mainValues["SUB_IDS"] = """["a","b","a"]"""
val ids = MmkvManager.decodeSubsList()
ids.remove("a")
ids.add(0, "c")
MmkvManager.encodeSubsList(ids)
assertEquals("""["c","b"]""", mainValues["SUB_IDS"])
assertEquals(listOf("c", "b"), MmkvManager.decodeSubsList())
}
@Test
fun missingBlankAndEmptyIndexesRemainEmpty() {
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
listOf("", " ", "[]", "null").forEach { stored ->
mainValues["SUB_IDS"] = stored
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
}
}
@Test
fun malformedIndexKeepsTheExistingEmptyFallback() {
mockStatic(Log::class.java).use {
mainValues["SUB_IDS"] = "{"
assertEquals(emptyList<String>(), MmkvManager.decodeSubsList())
}
}
companion object {
private val main: MMKV = mock()
private val subs: MMKV = mock()
private val settings: MMKV = mock()
@BeforeClass
@JvmStatic
fun initializeHandles() {
mockStatic(MMKV::class.java).use {
it.`when`<MMKV> { MMKV.mmkvWithID("MAIN", MMKV.MULTI_PROCESS_MODE) }.thenReturn(main)
it.`when`<MMKV> { MMKV.mmkvWithID("SUB", MMKV.MULTI_PROCESS_MODE) }.thenReturn(subs)
it.`when`<MMKV> { MMKV.mmkvWithID("SETTING", MMKV.MULTI_PROCESS_MODE) }.thenReturn(settings)
MmkvManager.decodeSubscriptions()
MmkvManager.decodeSettingsString("test-initialize")
}
}
}
}
@@ -0,0 +1,31 @@
package com.v2ray.ang.root
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.TimeUnit
class RootProcessRunnerTest {
@Test fun `a silent blocked command respects its deadline`() {
val start = System.nanoTime()
val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5"), 150)
assertEquals(-1, result.code)
assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500)
}
@Test fun `inherited stdout cannot hold a completed command open`() {
val start = System.nanoTime()
val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5 & echo complete"), 250)
assertEquals(0, result.code)
assertTrue(result.output.contains("complete"))
assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500)
}
@Test fun `large output is drained without unbounded retention`() {
val result = RootProcessRunner.run(listOf("sh", "-c", "yes x | head -c 1048576"), 3000, 4096)
assertEquals(0, result.code)
assertEquals(4096, result.output.length)
}
@Test fun `failure status and diagnostic output survive`() {
val result = RootProcessRunner.run(listOf("sh", "-c", "echo lock-unavailable >&2; exit 4"), 1000)
assertEquals(4, result.code)
assertTrue(result.output.contains("lock-unavailable"))
}
}
@@ -0,0 +1,206 @@
package com.v2ray.ang.service
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.Mockito.mockConstruction
import org.mockito.Mockito.mockStatic
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.FutureTask
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
class DialerWebviewServiceTest {
@Test
fun workerStartWaitsForMainThreadSetup() = Fixture().use { f ->
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
val queued = f.nextUpdate()
assertFalse(start.isDone)
assertTrue(f.views.isEmpty())
queued.run()
start.get(5, TimeUnit.SECONDS)
verify(f.views.single()).loadUrl("http://127.0.0.1:10001/")
}
@Test
fun workerStopCompletesCleanupBeforeReturning() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
val view = f.views.single()
val stop = f.onWorker { f.dialer.stop() }
val queued = f.nextUpdate()
assertFalse(stop.isDone)
verify(view, never()).destroy()
queued.run()
stop.get(5, TimeUnit.SECONDS)
verify(view).stopLoading()
verify(view).pauseTimers()
verify(view).onPause()
verify(view).destroy()
}
@Test
fun restartReplacesTheViewAndRepeatedStopDestroysItOnce() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
f.dialer.start(f.context, "127.0.0.1:10002")
verify(f.views.first()).destroy()
verify(f.views.last()).loadUrl("http://127.0.0.1:10002/")
f.dialer.stop()
f.dialer.stop()
f.views.forEach { verify(it, times(1)).destroy() }
}
@Test
fun mainThreadStopInvalidatesAQueuedWorkerStart() = Fixture().use { f ->
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
val queued = f.nextUpdate()
f.dialer.stop()
queued.run()
start.get(5, TimeUnit.SECONDS)
assertTrue(f.views.isEmpty())
}
@Test
fun interruptedWorkerCannotLeaveAQueuedStartBehind() = Fixture().use { f ->
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
val queued = f.nextUpdate()
start.cancel(true)
// The single worker reaches this marker only after handling the interruption.
f.onWorker {}.get(5, TimeUnit.SECONDS)
queued.run()
assertTrue(f.views.isEmpty())
}
@Test
fun rejectedMainThreadDispatchFailsWithoutWaiting() = Fixture().use { f ->
whenever(f.handler.post(any())).thenReturn(false)
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
val thrown = assertThrows(ExecutionException::class.java) { start.get(5, TimeUnit.SECONDS) }
assertTrue(thrown.cause is IllegalStateException)
assertTrue(f.views.isEmpty())
}
@Test
fun oldQueuedStopCannotDestroyANewerStart() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
val stop = f.onWorker { f.dialer.stop() }
val queued = f.nextUpdate()
f.dialer.start(f.context, "127.0.0.1:10002")
queued.run()
stop.get(5, TimeUnit.SECONDS)
verify(f.views.first()).destroy()
verify(f.views.last(), never()).destroy()
}
@Test
fun setupFailureCleansUpAndReachesTheWorkerUnwrapped() = Fixture().use { f ->
val failure = IllegalStateException("WebView setup failed")
f.loadFailure = failure
val start = f.onWorker { f.dialer.start(f.context, "127.0.0.1:10001") }
f.nextUpdate().run()
val thrown = assertThrows(ExecutionException::class.java) { start.get(5, TimeUnit.SECONDS) }
assertSame(failure, thrown.cause)
verify(f.views.single()).destroy()
f.dialer.stop()
verify(f.views.single(), times(1)).destroy()
}
@Test
fun cleanupFailureStillDestroysTheViewAndReachesTheCaller() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
val view = f.views.single()
val failure = IllegalStateException("WebView stop failed")
doThrow(failure).whenever(view).stopLoading()
assertSame(failure, assertThrows(IllegalStateException::class.java) { f.dialer.stop() })
f.dialer.stop()
verify(view, times(1)).destroy()
}
@Test
fun callbacksCannotResumeADestroyedViewOrRescheduleKeepAlive(): Unit = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
val view = f.views.single()
val client = argumentCaptor<WebViewClient>()
verify(view).webViewClient = client.capture()
val keepAlive = f.keepAlives.single()
f.dialer.stop()
clearInvocations(view, f.handler)
client.firstValue.onPageFinished(view, "http://127.0.0.1:10001/")
keepAlive.run()
verify(view, never()).onResume()
verify(view, never()).resumeTimers()
verify(f.handler, never()).postDelayed(any(), any<Long>())
}
@Test
fun emptyAddressStopsThePreviousViewWithoutCreatingAnother() = Fixture().use { f ->
f.dialer.start(f.context, "127.0.0.1:10001")
f.dialer.start(f.context, "")
assertEquals(1, f.views.size)
verify(f.views.single()).destroy()
}
private class Fixture : AutoCloseable {
private val mainLooper = mock<Looper>()
private val loopers = mockStatic(Looper::class.java).apply {
`when`<Looper> { Looper.getMainLooper() }.thenReturn(mainLooper)
`when`<Looper> { Looper.myLooper() }.thenReturn(mainLooper)
}
private val updates = LinkedBlockingQueue<Runnable>()
private val worker = Executors.newSingleThreadExecutor()
val keepAlives = mutableListOf<Runnable>()
var loadFailure: Exception? = null
val context = mock<Context>().also { whenever(it.applicationContext).thenReturn(it) }
private val handlers = mockConstruction(Handler::class.java) { handler, _ ->
whenever(handler.looper).thenReturn(mainLooper)
whenever(handler.post(any())).thenAnswer {
val task = it.getArgument<Runnable>(0)
if (task is FutureTask<*>) updates.add(task) else keepAlives.add(task)
true
}
}
private val webViews = mockConstruction(WebView::class.java) { view, _ ->
whenever(view.settings).thenReturn(mock<WebSettings>())
loadFailure?.let { doThrow(it).whenever(view).loadUrl(any()) }
}
val dialer = DialerWebviewService()
val handler get() = handlers.constructed().single()
val views get() = webViews.constructed()
fun onWorker(action: () -> Unit): Future<*> = worker.submit {
// Static mocks are thread-local: this worker has no Android looper.
mockStatic(Looper::class.java).use { action() }
}
fun nextUpdate(): Runnable = checkNotNull(updates.poll(5, TimeUnit.SECONDS)) {
"Worker did not dispatch its WebView operation to the main looper"
}
override fun close() {
worker.shutdownNow()
worker.awaitTermination(5, TimeUnit.SECONDS)
webViews.close()
handlers.close()
loopers.close()
}
}
}
@@ -0,0 +1,62 @@
package com.v2ray.ang.ui.logcat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class LogcatEntryTest {
@Test
fun emptyLogProducesNoEntries() {
assertTrue(createLogcatEntries(emptyList()).isEmpty())
}
@Test
fun identicalTimestampedLinesHaveUniqueKeysWithoutLosingMessages() {
val line = "08-31 04:32:41.187 I/GoLog (42): repeated message"
val logs = listOf(line, line, line)
val entries = createLogcatEntries(logs)
assertEquals(logs, entries.map { it.text })
assertEquals(3, entries.map { it.key }.distinct().size)
}
@Test
fun preservesNewestFirstOrderAndOriginalText() {
val logs = listOf("newest", "\tat repeatedFrame(File.kt:42)", "middle", "\tat repeatedFrame(File.kt:42)")
val entries = createLogcatEntries(logs)
assertEquals(logs, entries.map { it.text })
assertEquals(logs.joinToString("\n"), entries.joinToString("\n") { it.text })
assertEquals(logs.size, entries.map { it.key }.distinct().size)
}
@Test
fun filteringOtherLinesPreservesKeysOfEveryMatchingOccurrence() {
val logs = listOf("match", "other", "match", "last")
val entries = createLogcatEntries(logs)
val filtered = createLogcatEntries(logs.filter { it.contains("match") })
assertEquals(entries.filter { it.text.contains("match") }, filtered)
assertTrue(createLogcatEntries(logs.filter { it.contains("missing") }).isEmpty())
assertEquals(entries, createLogcatEntries(logs))
}
@Test
fun refreshWithNewerLinesPreservesExistingOccurrenceKeys() {
val logs = listOf("repeated", "older", "repeated")
val entries = createLogcatEntries(logs)
val refreshed = createLogcatEntries(listOf("newest", "repeated") + logs)
assertEquals(entries, refreshed.takeLast(entries.size))
assertEquals(refreshed.size, refreshed.map { it.key }.distinct().size)
}
@Test
fun emptyAndKeyLikeMessagesRemainDistinct() {
val logs = listOf("", "0:", "", "1:message", "message", "message")
val entries = createLogcatEntries(logs)
assertEquals(logs, entries.map { it.text })
assertEquals(logs.size, entries.map { it.key }.distinct().size)
}
}
@@ -0,0 +1,62 @@
package com.v2ray.ang.ui.main
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MainTestRequestsTest {
@Test
fun onlyTheLatestCurrentRequestCanFinishOnce() {
val requests = MainTestRequests()
assertFalse(requests.isTesting)
assertFalse(requests.completeCurrent(""))
val first = requests.beginCurrent()
val second = requests.beginCurrent()
assertNotEquals(first, second)
assertFalse(requests.completeCurrent(first))
assertTrue(requests.isTesting)
assertTrue(requests.completeCurrent(second))
assertFalse(requests.completeCurrent(second))
assertFalse(requests.isTesting)
}
@Test
fun stopAndRestartInvalidateThePreviousConnectionRequest() {
val requests = MainTestRequests()
val stopped = requests.beginCurrent()
requests.invalidateCurrent()
assertFalse(requests.completeCurrent(stopped))
val restarted = requests.beginCurrent()
assertFalse(requests.completeCurrent(stopped))
assertTrue(requests.completeCurrent(restarted))
}
@Test
fun bulkCancellationAndReplacementRejectLateCompletionEvenInTheSameGroup() {
val requests = MainTestRequests()
val cancelled = requests.beginBulk("group")
requests.cancelBulk()
assertNull(requests.completeBulk(cancelled.id))
val replaced = requests.beginBulk("group")
val current = requests.beginBulk("group")
assertNull(requests.completeBulk(replaced.id))
assertEquals(current, requests.bulk)
assertEquals(current, requests.completeBulk(current.id))
assertNull(requests.completeBulk(current.id))
assertFalse(requests.isTesting)
}
@Test
fun currentResultDoesNotFinishAnIndependentBulkTest() {
val requests = MainTestRequests()
val bulk = requests.beginBulk("") // All servers is a valid group ID.
val current = requests.beginCurrent()
assertTrue(requests.completeCurrent(current))
assertTrue(requests.isTesting)
assertEquals(bulk, requests.completeBulk(bulk.id))
assertFalse(requests.isTesting)
}
}
@@ -0,0 +1,29 @@
package com.v2ray.ang.ui.main
import androidx.compose.runtime.saveable.SaverScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ServerDeleteTargetTest {
@Test
fun pendingDeletionRestoresItsGuidAndDisplayName() {
val scope = SaverScope { it is String }
for (guid in listOf("first-guid", "second-guid")) {
for (name in listOf("Same name", "", "Профиль\n服务器 100%")) {
val target = ServerDeleteTarget(guid, name)
val saved = with(ServerDeleteTarget.Saver) { scope.save(target) }
assertEquals(listOf(guid, name), saved)
assertEquals(target, ServerDeleteTarget.Saver.restore(requireNotNull(saved)))
}
}
}
@Test
fun noPendingDeletionHasNoSavedTarget() {
val saved = with(ServerDeleteTarget.Saver) { SaverScope { it is String }.save(null) }
assertNull(saved)
}
}
@@ -0,0 +1,60 @@
package com.v2ray.ang.ui.server
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class ProxyChainMembersTest {
@Test
fun removalFollowsThePendingKeyAfterReordering() {
val pendingKey = "two"
val members = listOf("Two", "One", "Three")
val keys = listOf("two", "one", "three")
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, keys, pendingKey)
assertEquals(listOf("One", "Three"), remainingMembers)
assertEquals(listOf("one", "three"), remainingKeys)
assertEquals(listOf("Two", "One", "Three"), members)
assertEquals(listOf("two", "one", "three"), keys)
}
@Test
fun duplicateNamesDoNotChangeWhichMemberIsRemoved() {
val (members, keys) = withoutProxyChainMember(
listOf("Same", "Same", "Other"),
listOf("first", "second", "third"),
"second",
)
assertEquals(listOf("Same", "Other"), members)
assertEquals(listOf("first", "third"), keys)
}
@Test
fun missingMemberLeavesBothListsUnchanged() {
val members = listOf("One", "Three")
val keys = listOf("one", "three")
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, keys, "two")
assertSame(members, remainingMembers)
assertSame(keys, remainingKeys)
}
@Test
fun emptyChainRemainsEmpty() {
assertEquals(
emptyList<String>() to emptyList<String>(),
withoutProxyChainMember(emptyList(), emptyList(), "missing"),
)
}
@Test
fun blankMemberIsRemovedWithItsOwnKey() {
assertEquals(
listOf("One") to listOf("one"),
withoutProxyChainMember(listOf("One", ""), listOf("one", "blank"), "blank"),
)
}
}
@@ -0,0 +1,44 @@
package com.v2ray.ang.ui.settings
import android.app.Application
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.Mockito.mockConstruction
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class SettingsViewModelTest {
@Test
fun vpnSettingsVisibilityFollowsActivityAvailability() = runBlocking(Dispatchers.IO) {
val packageManager = mock<PackageManager>()
val application = mock<Application>()
whenever(application.packageManager).thenReturn(packageManager)
var activity: ComponentName? = null
mockConstruction(Intent::class.java) { intent, context ->
assertEquals(listOf(Settings.ACTION_VPN_SETTINGS), context.arguments())
whenever(intent.resolveActivity(packageManager)).thenAnswer { activity }
}.use {
val viewModel = SettingsViewModel(application)
assertFalse(viewModel.systemVpnSettingsAvailable.value)
viewModel.refreshSystemVpnSettingsAvailability()
assertFalse(viewModel.systemVpnSettingsAvailable.value)
activity = mock<ComponentName>()
viewModel.refreshSystemVpnSettingsAvailability()
assertTrue(viewModel.systemVpnSettingsAvailable.value)
activity = null
viewModel.refreshSystemVpnSettingsAvailability()
assertFalse(viewModel.systemVpnSettingsAvailable.value)
}
}
}
@@ -0,0 +1,45 @@
package com.v2ray.ang.ui.userasset
import android.app.Application
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.Mockito.mock
class UserAssetViewModelTest {
private val viewModel = UserAssetViewModel(mock(Application::class.java))
@Test
fun builtInIdentitySurvivesReloadAndSourceChange() {
val first = viewModel.buildAssetList(null, "first/source")
val reloaded = viewModel.buildAssetList(emptyList(), "second/source")
assertEquals(3, first.size)
assertEquals(first.size, first.map { it.guid }.distinct().size)
assertEquals(first.map { it.guid }, reloaded.map { it.guid })
assertTrue(reloaded.all { it.assetUrl.locked == true })
assertEquals(
AppConfig.GEOIP_ONLY_CN_PRIVATE_URL,
reloaded.single { it.assetUrl.remarks == AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT }.assetUrl.url,
)
}
@Test
fun savedAssetKeepsItsIdentityAndReplacesMatchingBuiltIn() {
val saved = AssetUrlCache("saved-guid", AssetUrlItem(AppConfig.GEOSITE_DAT, "https://example.invalid/geosite.dat"))
val custom = AssetUrlCache("custom-guid", AssetUrlItem("custom.dat", "file"))
val builtIns = viewModel.buildAssetList(emptyList(), "source")
val rows = viewModel.buildAssetList(listOf(saved, custom), "source")
assertEquals(4, rows.size)
assertEquals(saved, rows.single { it.assetUrl.remarks == saved.assetUrl.remarks })
assertEquals(custom, rows.single { it.guid == custom.guid })
assertEquals(
builtIns.filter { it.assetUrl.remarks != AppConfig.GEOSITE_DAT }.map { it.guid },
rows.filter { it.assetUrl.locked == true }.map { it.guid },
)
}
}
+9 -9
View File
@@ -1,26 +1,26 @@
[versions]
agp = "9.3.2"
agp = "9.4.1"
desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.9"
kotlin = "2.4.10"
gradleLicensePlugin = "0.9.91"
kotlin = "2.4.20"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
activity = "1.13.0"
appcompat = "1.7.1"
appcompat = "1.8.0"
mmkvStatic = "1.3.16"
gson = "2.14.0"
okhttp = "5.4.0"
okhttp = "5.5.0"
kotlinxCoroutines = "1.11.0"
core = "3.5.4"
workRuntimeKtx = "2.11.2"
lifecycleViewmodelKtx = "2.11.0"
mockitoMockitoInline = "5.2.0"
camerax = "1.6.1"
composeBom = "2026.06.01"
camerax = "1.6.2"
composeBom = "2026.09.00"
reorderable = "3.1.0"
coil = "2.7.0"
coil = "3.6.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
@@ -57,7 +57,7 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
+1 -1
View File
@@ -1,6 +1,6 @@
#Thu Nov 14 12:42:51 BDT 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists