Compare commits

...

46 Commits

Author SHA1 Message Date
2dust b5bcd81ae4 up 2.1.7 2026-05-10 14:17:02 +08:00
2dust d45ccd7953 Update AndroidLibXrayLite 2026-05-10 14:16:54 +08:00
solokot 6815563902 Update Russian translation (#5621) 2026-05-10 14:07:00 +08:00
2dust 7c38ad7c57 Improve WireGuard addresses 2026-05-09 16:48:09 +08:00
2dust 885aeb384e Refactor TCP HTTP request builder, relax Pragma
https://github.com/2dust/v2rayNG/issues/5623
2026-05-09 16:35:11 +08:00
2dust 57f01412e7 up 2.1.6 2026-05-09 11:42:37 +08:00
2dust fd84ea4114 Code cleanup: imports, formatting, and minor fixes 2026-05-09 11:42:23 +08:00
2dust a4e2e6d6a2 Fix,reverse proxy chain profiles order 2026-05-09 11:05:27 +08:00
2dust b8cfe9bc82 bump AGP to 9.2.1 2026-05-09 11:04:59 +08:00
DHR60 b945f4a3c7 OkHttp Dialer (#5579)
AI
Add WebView dialer

Co-authored-by: fish4terrisa-MSDSM <flyingfish.msdsm@gmail.com>
2026-05-07 13:43:34 +08:00
fanymagnet cfb6776e8f Get profile desc from server.address/port for vless (#5610)
Co-authored-by: fanymagnet <>
2026-05-07 10:03:47 +08:00
fanymagnet ebaa5088b6 Show the save button and reboot the core upon saving (#5600)
Co-authored-by: fanymagnet <>
2026-05-05 19:23:41 +08:00
2dust 209679f098 Improve V2Ray config/error handling and startup
Add richer error reporting and simplify control flow across core components. Key changes:

- Add errorMessage to ConfigResult and populate it on failures in CoreConfigManager.getV2rayConfig/getV2rayConfig4Speedtest.
- Replace many nullable/boolean error returns in CoreConfigManager with explicit errors (error()/exceptions) and non-null return types; tighten validation (invalid servers, missing assets, parsing errors) and simplify inbound/dns/routing/outbound setup logic.
- Make getInbounds/getRouting/getDns/getOutbounds/getCustomLocalDns/getRoutingUserRule/getBalance non-failing (throw on error) and remove silent try/catch patterns; improve logging and fallback behaviors.
- CoreServiceManager: wrap startContextService calls with try/catch to surface errors to UI; startContextService now throws on invalid state/config and moves startup loop into doStartCoreLoop which registers receivers, starts core loop, and reports detailed failures to UI.
- Show notification when VPN service starts (CoreVpnService) to ensure foreground visibility.
- MainViewModel: display detailed start failure message from intent when available instead of only a generic toast.

These changes improve diagnostics, reduce silent failures, and centralize error handling during startup.
2026-05-05 18:45:47 +08:00
2dust 19ad248cfe Apply Basic auth from URL userinfo
https://github.com/2dust/v2rayNG/issues/5583
2026-05-05 10:40:09 +08:00
solokot 35f1f58476 Update Russian translation (#5595) 2026-05-04 10:52:11 +08:00
2dust 4c86e7c26e up 2.1.5 2026-05-03 20:21:49 +08:00
2dust 5d0897cd94 Update AndroidLibXrayLite 2026-05-03 20:21:17 +08:00
2dust fc4ab733c8 Introduce RealPingEvent and refactor ping flow
Add RealPingEvent sealed class and refactor real-ping flow to use event-driven communication. RealPingWorkerService now emits Progress/Result/Finish events instead of direct callbacks/MessageUtil calls. CoreTestService reorganized into handlers (handleMeasureStart/handleMeasureCancel/handleWorkerEvent) to process events, update notifications, persist delays via MmkvManager, and notify UI. MainViewModel adjusted to read the result content as a string when handling success messages. Overall change decouples worker logic from service responsibilities and simplifies progress/result handling.
2026-05-03 16:46:40 +08:00
DHR60 d606453b83 Fix (#5593) 2026-05-03 16:08:52 +08:00
DHR60 c6c8eccd51 Opt dns (#5592) 2026-05-03 14:52:55 +08:00
2dust 03074fba61 Add NotificationHelper and improve core test service
Introduce unified notification support and use foreground notifications for core test tasks. Added NotificationChannelType enum and NotificationHelper (cached NotificationManager and Builder cache, channel creation, notify/update/startForeground/stopForeground/cancel). Updated AndroidManifest to mark CoreTestService as a specialUse foreground service. Renamed AppConfig message constants (MSG_MEASURE_CONFIG_START, MSG_MEASURE_CONFIG_CANCEL, MSG_MEASURE_CONFIG_SUCCESS) and updated usages: MessageUtil now starts a foreground service on O+ for measurement start and handles cancel without forcing start; CoreTestService starts foreground immediately, logs lifecycle, manages RealPingWorkerService via onProgress/onFinish callbacks, and stops foreground when done; RealPingWorkerService reports progress via onProgress instead of direct UI messages; SubscriptionUpdater now uses NotificationHelper and NotificationChannelType for update notifications. These changes improve notification performance, ensure startForegroundService compliance on Android O+, and centralize notification logic.
2026-05-03 14:44:38 +08:00
2dust ac02d23083 Add real-ping concurrency setting
Introduce a configurable concurrency for real-ping (true delay) tests. Add PREF_REAL_PING_CONCURRENCY and DEFAULT_REAL_PING_CONCURRENCY in AppConfig, a getter SettingsManager.getRealPingConcurrency() (parses stored value, defaults to 16, clamps to 1..128), and use that value to create the RealPingWorkerService thread pool instead of cpu * 4. Expose the setting in preferences (EditTextPreference) and add localized title strings across multiple resource files. This lets users control how many concurrent real-ping workers run.
2026-05-03 09:42:36 +08:00
David 5c6aaf285b Correct translation errors (#5588)
* Update Chinese strings for UI and settings

* Refine summary for auto-remove invalid config

* Refine summaries for auto-remove and auto-sort preferences

* Change UI color setting label to '深色模式'
2026-05-03 09:16:32 +08:00
DHR60 d3f9809486 Fix dns (#5587) 2026-05-03 09:09:10 +08:00
2dust a12909e33d Add proxy-chain feature (UI & core support)
Introduce a new Proxy Chain server type and full support across UI and core logic. Added EConfigType.PROXYCHAIN and ProfileItem.proxyChainProfiles, new ServerProxyChainActivity, ServerProxyChainMemberAdapter, layouts and menu entries, and localized strings. CoreConfigContextBuilder now resolves proxy-chain members (from explicit list or subscription group) with robust filtering and error logging; CoreConfigManager validates chain length and logs warnings. CoreOutboundBuilder and CoreServiceManager were updated to treat PROXYCHAIN as a non-directly-runnable config type. Various UI flows (GroupServerFragment, MainActivity, ServerActivity) updated to handle the new type.
2026-05-02 19:00:17 +08:00
2dust 3e823a7440 Pre-resolve and inject custom outbounds
Pre-resolve outbound profiles referenced by routing rules and pass them through the runtime context so they can be injected into generated V2ray configs.

Key changes:
- Added customOutboundProfiles: Map<String, ProfileItem> to CoreConfigContext.
- CoreConfigContextBuilder: new resolveCustomOutbounds() to collect non-builtin outbound tags from routing rulesets and build the map; builder now populates customOutboundProfiles and simplifies resolvedProfiles/type handling.
- CoreConfigManager: refactored many APIs to accept CoreConfigContext instead of raw Context/guid (toConfigResult, initV2rayConfig, getRouting, getOutbounds, getBalance, etc.), and uses the pre-resolved customOutboundProfiles when injecting custom outbounds.
- injectCustomOutbounds now accepts the pre-resolved map and converts ProfileItem -> Outbound only for missing tags.
- Added logging/error handling and minor tidying to avoid repeated lookups and ensure custom outbounds are available before routing is applied.

Rationale: ensure outbounds referenced by rulesets are available early in the build pipeline, reduce duplicate lookups, and centralize runtime data in CoreConfigContext.
2026-05-02 14:47:03 +08:00
2dust dc1d3a27dd Introduce CoreConfigContext and refactor manager
Add CoreConfigContext DTO and CoreResolvedType enum, and implement CoreConfigContextBuilder to centralize profile resolution (CUSTOM, POLICYGROUP, PROXYCHAIN/NORMAL) with policy-group filters and proxy-chain ordering (next -> current -> prev).

Refactor CoreConfigManager to consume CoreConfigContext: replace prior branching with buildGroupConfig, buildProxyChainConfig and buildNormalConfig, unify CUSTOM handling, add postProcessForSpeedtest and toConfigResult helpers, and remove the old getMoreOutbounds logic. Adjust outbound assembly, DNS/route handling and speedtest post-processing accordingly.

Minor cleanup: centralize outbound conversion comments in CoreOutboundBuilder and tidy several condition checks and log lines. Overall this extracts resolution logic, reduces duplication and clarifies the runtime build flow.
2026-05-02 10:53:53 +08:00
2dust d0f1e21f49 Move outbound global settings to builder
Extract updateOutboundWithGlobalSettings from CoreConfigManager into CoreOutboundBuilder and apply it during outbound conversion. CoreConfigManager no longer calls the function (and its NetworkType import was removed); CoreOutboundBuilder.convert now runs the global-settings update and returns null if the update fails. This centralizes outbound/global preference handling and eliminates duplicated update logic across the codebase.
2026-05-01 19:11:21 +08:00
2dust 3b8615fad0 Rename V2Ray components to Core
Refactor: rename handler package/classes and services from V2Ray*/V2ray* to Core*/Core (e.g. V2rayConfigManager -> CoreConfigManager, V2RayNativeManager -> CoreNativeManager, V2RayServiceManager -> CoreServiceManager, and service classes V2RayVpnService/V2RayProxyOnlyService/V2RayTestService -> CoreVpnService/CoreProxyOnlyService/CoreTestService). Update AndroidManifest service entries and all imports/usages across the app (notifications, receivers, widgets, UI, services, workers) to reference the renamed classes and package (com.v2ray.ang.core). Comments and references were adjusted to match the new names; no functional logic changes intended.
2026-05-01 18:48:43 +08:00
2dust d9d00f4137 Add CoreOutboundBuilder to build OutboundBeans
Introduce CoreOutboundBuilder.kt to centralize ProfileItem → OutboundBean conversion logic. Provides createInitOutbound and per-protocol builders for VMESS, VLESS, Shadowsocks, SOCKS, Trojan, WireGuard, Hysteria2 and HTTP. Includes reusable helpers populateTransportSettings and populateTlsSettings to configure network transport, TLS/REALITY settings, finalmask manipulation and optional fragmentation via MMKV prefs. Also adds domain resolution logic (with IPv6 preference) in getServerAddress and preserves original format-specific behaviors.
2026-05-01 17:06:53 +08:00
2dust 2aefbd43eb Suppress exceptions in RealPingWorkerService
Wrap startRealPing(guid) in a catch(Throwable) that ignores errors so exceptions during measurement won't propagate. This ensures the finally block always runs to decrement totalCount and runningCount, preventing the worker from being interrupted by unexpected errors.
2026-05-01 17:06:34 +08:00
2dust cf33391785 up 2.1.4 2026-04-30 11:30:31 +08:00
dependabot[bot] a35ba5dfbd Bump timheuer/base64-to-file from 1.2.4 to 2.0.0 (#5575)
Bumps [timheuer/base64-to-file](https://github.com/timheuer/base64-to-file) from 1.2.4 to 2.0.0.
- [Release notes](https://github.com/timheuer/base64-to-file/releases)
- [Commits](https://github.com/timheuer/base64-to-file/compare/v1.2.4...v2.0.0)

---
updated-dependencies:
- dependency-name: timheuer/base64-to-file
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 10:52:30 +08:00
Copilot 3b4d895c1d Refactor: centralize subscription auto-update scheduling in SubscriptionUpdater (#5567)
* Initial plan

* Refactor subscription auto-update system into SubscriptionUpdater facade

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/63125c89-4db9-4572-8adb-10c29ea531f5

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>

* Add TODO comment to legacy fallback in SubscriptionUpdater

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/63125c89-4db9-4572-8adb-10c29ea531f5

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>

* Use per-subscription auto-update intervals

Migrate subscription auto-update from a global toggle/interval to per-subscription scheduling.

- Add per-subscription updateInterval (Long, default 1440 minutes) and persist in SubEdit UI; validate minimum interval (15 minutes).
- Update SubscriptionUpdater to schedule/cancel tasks per subscription, compute initial delay from subscription.lastUpdated, and skip when autoUpdate is disabled or subId is missing.
- Remove legacy global auto-update prefs and fallback logic: deleted global prefs/UI handling, MmkvManager last-attempt encode/decode, and related default setting.
- Ensure cancelling of scheduled task when removing a subscription and adjust SettingsManager initialization.
- Add localized toast strings for invalid update interval and update related layouts/resources.

This change enables individual update intervals per subscription and simplifies the updater by removing legacy global state and last-attempt persistence.

* Allow optional force-reschedule for subscriptions

Add a forceReschedule flag to SubscriptionUpdater.sync to choose the WorkManager policy (KEEP by default, REPLACE when forced). Refactor scheduleOne to accept an ExistingPeriodicWorkPolicy and use it when enqueuing periodic work; syncOne now forces REPLACE so a manual refresh recalculates next run. Remove the cancelAll helper and improve logging to include the reschedule flag/policy. This lets startup keep existing periodic work while allowing explicit reschedules when needed (e.g., after manual updates).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-30 10:52:14 +08:00
2dust e8585a82c6 Remove echForceQuery
Remove echForceQuery support across the app: delete the echForceQuery fields from ProfileItem and V2rayConfig, stop mapping it in V2rayConfigManager, and remove the related UI spinner and containers in ServerActivity and layout_tls.xml. Also delete the ech_force_query_value array and all localized string entries for the ECH force query label, and adjust focus navigation to skip the removed control. This cleans up a deprecated/unused ECH force query setting.
2026-04-29 20:53:28 +08:00
Copilot 087c08f95a fix: immediately reconfigure WorkManager when auto-update interval changes (#5564)
* Initial plan

* fix: reconfigure WorkManager task when auto-update interval changes

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/2fd439e4-ecb0-4594-a90f-fbfe1e81b021

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-27 20:59:02 +08:00
Hossein Abaspanah 6612ca8256 Update Luri Bakhtiari translation (#5554) 2026-04-27 18:55:03 +08:00
solokot 487692deb7 Update Russian translation (#5553) 2026-04-27 18:54:47 +08:00
2dust e9332168bf up 2.1.3 2026-04-26 11:23:14 +08:00
2dust 15fcfec4c8 Update AndroidLibXrayLite 2026-04-26 11:19:29 +08:00
hippo2025 a4baeeb0fa Add options to turn off local proxy completely or turn off UDP in SOCKS5 (#5548)
* Add options to turn off local proxy completely or turn off UDP in SOCKS5.

* Improve consistency of code style

* Auto-disable additional http proxy when local proxy is disabled

* Add local proxy summary and wire to prefs

* Update V2rayNG/app/src/main/res/values/strings.xml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: hippo2025 <4xcw60t6e@mozmail.com>
Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 11:14:00 +08:00
2dust 093e51186d Upgrade Android Gradle Plugin and Gradle wrapper
Bump AGP to 9.2.0 in gradle/libs.versions.toml and update Gradle wrapper distribution to gradle-9.4.1 in gradle/wrapper/gradle-wrapper.properties. Keeps the build tooling up to date to take advantage of Gradle/AGP fixes and improvements.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-25 20:11:03 +08:00
2dust 14a29884e7 Disable VPN session name setting
Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-25 20:10:33 +08:00
solokot 30b0bd8d0d Update Russian translation (#5538)
* Update Russian translation

* Update Russian translation
2026-04-25 19:56:04 +08:00
hippo2025 ae84df7367 Add MTU and TTI settings in KCP network (#5539)
* Add MTU and TTI settings in KCP network

* Rename ProfileItem.tti to kcpTti

Rename the ProfileItem field `tti` to `kcpTti` and update all usages accordingly. Changes update parsing/serialization in FmtBase (map query param "tti" to config.kcpTti and emit it), KCP stream settings mapping in V2rayConfigManager, UI bindings in ServerActivity, and equality checks in ProfileItem to use kcpTti. This clarifies that the field applies to KCP-specific TTI values while preserving the external query key.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>

* Add kcpMtu/kcpTti for KCP transport

Introduce kcpMtu and kcpTti properties on ProfileItem and update equality checks. Parse and emit KCP-specific mtu/tti in FmtBase (use kcpMtu/kcpTti instead of the generic mtu). Wire kcpMtu into V2rayConfigManager when building KCP stream settings and update ServerActivity to read/write the UI field to profileItem.kcpMtu/kcpTti. This separates KCP MTU/TTI from the general mtu field and removes a duplicated kcpTti entry.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-25 19:55:44 +08:00
hippo2025 b673167731 Return null in IP test if http port is 0 (#5537)
new name

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
2026-04-25 19:13:23 +08:00
89 changed files with 4513 additions and 2321 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ jobs:
java-version: '21'
- name: Decode Keystore
uses: timheuer/base64-to-file@v1.2.4
uses: timheuer/base64-to-file@v2.0.0
id: android_keystore
with:
fileName: "android_keystore.jks"
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 36
versionCode = 722
versionName = "2.1.2"
versionCode = 727
versionName = "2.1.7"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
+18 -4
View File
@@ -82,6 +82,10 @@
android:name=".ui.ServerGroupActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
<activity
android:name=".ui.ServerProxyChainActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
<activity
android:name=".ui.SettingsActivity"
android:exported="false" />
@@ -168,7 +172,7 @@
android:exported="false" />
<service
android:name=".service.V2RayVpnService"
android:name=".service.CoreVpnService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
@@ -188,7 +192,7 @@
</service>
<service
android:name=".service.V2RayProxyOnlyService"
android:name=".service.CoreProxyOnlyService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
@@ -199,9 +203,14 @@
</service>
<service
android:name=".service.V2RayTestService"
android:name=".service.CoreTestService"
android:exported="false"
android:process=":RunSoLibV2RayDaemon" />
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
</service>
<receiver
android:name=".receiver.WidgetProvider"
@@ -273,6 +282,11 @@
</provider>
<service
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
android:exported="false"
android:process=":bg" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.cache"
@@ -43,10 +43,8 @@ object AppConfig {
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
const val SUBSCRIPTION_AUTO_UPDATE = "pref_auto_update_subscription"
const val SUBSCRIPTION_AUTO_UPDATE_INTERVAL = "pref_auto_update_interval"
const val SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL = "1440" // Default is 24 hours
const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
const val SUBSCRIPTION_MIN_INTERVAL_MINUTES = 15L
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
@@ -58,10 +56,12 @@ object AppConfig {
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
const val PREF_ENABLE_LOCAL_PROXY = "pref_enable_local_proxy"
const val PREF_SOCKS_PORT = "pref_socks_port"
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
const val PREF_SOCKS_USERNAME = "pref_socks_username"
const val PREF_SOCKS_PASSWORD = "pref_socks_password"
const val PREF_SOCKS_ENABLE_UDP = "pref_socks_enable_udp"
const val PREF_REMOTE_DNS = "pref_remote_dns"
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
const val PREF_DNS_HOSTS = "pref_dns_hosts"
@@ -78,6 +78,7 @@ object AppConfig {
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
@@ -164,17 +165,15 @@ object AppConfig {
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
const val MSG_MEASURE_DELAY_SUCCESS = 61
const val MSG_MEASURE_CONFIG = 7
const val MSG_MEASURE_CONFIG_SUCCESS = 71
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_START = 7
const val MSG_MEASURE_CONFIG_CANCEL = 71
const val MSG_MEASURE_CONFIG_SUCCESS = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
/** Protocols Scheme **/
const val VMESS = "vmess://"
@@ -0,0 +1,196 @@
package com.v2ray.ang.core
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.CoreResolvedType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
/**
* Builds [com.v2ray.ang.dto.CoreConfigContext] from the selected profile.
* Keeps parsing and resolution logic out of [CoreConfigManager].
*/
object CoreConfigContextBuilder {
/** Loads profile by guid and returns a resolved runtime context. */
fun build(context: Context, guid: String): CoreConfigContext? {
val config = MmkvManager.decodeServerConfig(guid) ?: return null
if (config.configType == EConfigType.CUSTOM) {
return CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
resolvedProfiles = listOf(config),
resolvedType = CoreResolvedType.CUSTOM,
)
}
// Pre-resolve custom outbound profiles from routing rulesets
val customOutbounds = resolveCustomOutbounds()
// Determine resolved profiles and type based on config type
val (resolvedProfiles, resolvedType) = when (config.configType) {
EConfigType.POLICYGROUP -> {
val profiles = resolvePolicyGroupProfiles(config)
Pair(profiles, CoreResolvedType.POLICYGROUP)
}
EConfigType.PROXYCHAIN -> {
val profiles = resolveProxyChainProfiles(config)
Pair(profiles, CoreResolvedType.PROXYCHAIN)
}
else -> {
val chainProfiles = resolveProxyChainProfilesFromGroup(config)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
}
// Create context with common fields
return CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
resolvedProfiles = resolvedProfiles,
resolvedType = resolvedType,
customOutboundProfiles = customOutbounds,
)
}
/** Resolves policy-group members with the same filters as runtime build. */
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
try {
val serverList = MmkvManager.decodeAllServerList()
return serverList
.asSequence()
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
.filter { profile ->
val subscriptionId = config.policyGroupSubscriptionId
if (subscriptionId.isNullOrBlank()) {
true
} else {
profile.subscriptionId == subscriptionId
}
}
.filter { profile ->
val filter = config.policyGroupFilter
if (filter.isNullOrBlank()) {
true
} else {
try {
Regex(filter).containsMatchIn(profile.remarks)
} catch (_: Exception) {
profile.remarks.contains(filter)
}
}
}
.filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.toList()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for config '${config.remarks}'", e)
return listOf(config)
}
}
/** Resolves proxy-chain members with the same filters as runtime build. */
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
if (config.proxyChainProfiles.isNullOrBlank()) {
return listOf(config)
}
try {
return config.proxyChainProfiles.orEmpty().split(",")
.asSequence()
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
.filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.toList()
.reversed()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for config '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolves chain nodes in fixed order: next -> current -> prev.
* If chain cannot be built, caller treats result as normal mode.
*/
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
return listOf(config)
}
if (config.subscriptionId.isEmpty()) {
return listOf(config)
}
try {
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
val resolved = mutableListOf<ProfileItem>()
// Keep the same practical chain order as current runtime assembly:
// next -> current -> prev
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
resolved.add(config)
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
return resolved
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles from group for config '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolves custom outbound profiles from routing rulesets.
* Scans rulesets for non-builtin outbound tags and looks up matching profiles by remarks.
* Returns a map of tag -> ProfileItem for independent processing.
*/
private fun resolveCustomOutbounds(): Map<String, ProfileItem> {
val customMap = mutableMapOf<String, ProfileItem>()
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return customMap
try {
val processedTags = mutableSetOf<String>()
rulesetItems
.filter { it.enabled }
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
.distinct()
.forEach { tag ->
if (tag in processedTags) return@forEach
processedTags.add(tag)
try {
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
return@forEach
}
customMap[tag] = profile
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound for tag '$tag', skipping", e)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound profiles", e)
}
return customMap
}
}
@@ -0,0 +1,950 @@
package com.v2ray.ang.core
import android.content.Context
import android.text.TextUtils
import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.CoreResolvedType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.PackageUidResolver
import com.v2ray.ang.util.Utils
object CoreConfigManager {
private var initConfigCache: String? = null
private var initConfigCacheWithTun: String? = null
//region get config function
/**
* Builds normal runtime config JSON for the selected profile.
*
* @param context The context of the caller.
* @param guid The unique identifier for the V2ray configuration.
* @return A ConfigResult object containing the configuration details or indicating failure.
*/
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
try {
val configContext = CoreConfigContextBuilder.build(context, guid)
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
if (configContext.resolvedType == CoreResolvedType.CUSTOM) {
return getV2rayCustomConfig(configContext)
}
val v2rayConfig = when (configContext.resolvedType) {
CoreResolvedType.POLICYGROUP -> buildGroupConfig(configContext)
CoreResolvedType.PROXYCHAIN -> buildProxyChainConfig(configContext)
CoreResolvedType.NORMAL -> buildNormalConfig(configContext)
CoreResolvedType.CUSTOM -> error("Unexpected CUSTOM resolved type")
}
return toConfigResult(configContext, v2rayConfig)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config", e)
return ConfigResult(
status = false,
guid = guid,
errorMessage = "Failed to get V2ray config: ${e.message ?: e.javaClass.simpleName}"
)
}
}
/**
* Builds speedtest config for the selected profile.
*
* It reuses the same build flow as normal config, then removes
* unnecessary sections for delay testing.
*
* @param context The context of the caller.
* @param guid The unique identifier for the V2ray configuration.
* @return A ConfigResult object containing the configuration details or indicating failure.
*/
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
try {
val configContext = CoreConfigContextBuilder.build(context, guid)
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
if (configContext.resolvedType == CoreResolvedType.CUSTOM) {
return getV2rayCustomConfig(configContext)
}
val v2rayConfig = when (configContext.resolvedType) {
CoreResolvedType.POLICYGROUP -> buildGroupConfig(configContext)
CoreResolvedType.PROXYCHAIN -> buildProxyChainConfig(configContext)
CoreResolvedType.NORMAL -> buildNormalConfig(configContext)
CoreResolvedType.CUSTOM -> error("Unexpected CUSTOM resolved type")
}
postProcessForSpeedtest(v2rayConfig)
return toConfigResult(configContext, v2rayConfig)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
return ConfigResult(
status = false,
guid = guid,
errorMessage = "Failed to get V2ray config for speedtest: ${e.message ?: e.javaClass.simpleName}"
)
}
}
/**
* Builds config result for CUSTOM profiles.
*/
private fun getV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
val context = configContext.context
val raw = MmkvManager.decodeServerRaw(configContext.guid)
?: return ConfigResult(status = false, guid = configContext.guid, errorMessage = "Custom config is empty")
val result = ConfigResult(true, configContext.guid, raw)
if (!needTun()) {
return result
}
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
// Check whether package names need to be replaced with UIDs
if (SettingsManager.canUseProcessRouting()) {
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
?: JsonArray()
for (elem in rulesJson) {
val rule = elem.takeIf { it.isJsonObject }?.asJsonObject ?: continue
val process = rule.get("process")?.takeIf { it.isJsonArray }?.asJsonArray ?: continue
val packages = process.mapNotNull {
it.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
}.takeIf { it.isNotEmpty() } ?: continue
val uids = PackageUidResolver.packageNamesToUids(context, packages).takeIf { it.isNotEmpty() } ?: continue
rule.add("process", JsonArray().apply { uids.forEach { add(it) } })
}
}
// check if tun inbound exists
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
?: JsonArray().also { json.add("inbounds", it) }
val tunNotExists = inboundsJson.none { elem ->
elem.isJsonObject && elem.asJsonObject.get("protocol")
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
?.asString == "tun"
}
if (tunNotExists) {
// add tun inbound from template
val templateConfig = initV2rayConfig(configContext)
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
}
}
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, configContext.guid, it) } ?: result
}
/** Builds full config for policy-group mode. */
private fun buildGroupConfig(configContext: CoreConfigContext): V2rayConfig {
val config = configContext.selectedProfile
val validConfigs = configContext.resolvedProfiles
if (validConfigs.isEmpty()) {
error("All configs are invalid")
}
val v2rayConfig = initV2rayConfig(configContext)
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
getInbounds(v2rayConfig)
v2rayConfig.outbounds.removeAt(0)
val outboundsList = mutableListOf<V2rayConfig.OutboundBean>()
var index = 0
for (item in validConfigs) {
index++
val outbound = convertProfile2Outbound(item) ?: continue
outbound.tag = "proxy-$index-${item.remarks.trim()}"
outboundsList.add(outbound)
}
outboundsList.addAll(v2rayConfig.outbounds)
v2rayConfig.outbounds = ArrayList(outboundsList)
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
getBalance(configContext, v2rayConfig)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
getCustomLocalDns(v2rayConfig)
}
if (!MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED)) {
v2rayConfig.stats = null
v2rayConfig.policy = null
}
// Resolve and add to DNS Hosts
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
resolveOutboundDomainsToHosts(v2rayConfig)
}
return v2rayConfig
}
/**
* Builds full V2Ray config for proxy-chain mode.
*
* Uses resolvedProfiles as an ordered chain and links each hop
* with dialerProxy.
*/
private fun buildProxyChainConfig(configContext: CoreConfigContext): V2rayConfig {
val config = configContext.selectedProfile
val resolvedProfiles = configContext.resolvedProfiles
val v2rayConfig = initV2rayConfig(configContext)
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
getInbounds(v2rayConfig)
// Build and link the whole chain directly from resolvedProfiles.
val chainOutbounds = resolvedProfiles.mapNotNull { profile ->
convertProfile2Outbound(profile)
}.toMutableList()
if (chainOutbounds.size < 2) {
error("Proxy chain requires at least 2 valid profiles, but only ${chainOutbounds.size} found")
}
chainOutbounds.forEachIndexed { index, outbound ->
outbound.tag = if (index == 0) AppConfig.TAG_PROXY else AppConfig.TAG_PROXY + index
}
for (index in 0 until chainOutbounds.size - 1) {
chainOutbounds[index].ensureSockopt().dialerProxy = chainOutbounds[index + 1].tag
}
// Keep built-in outbounds and place the chain before them.
val builtinOutbounds = if (v2rayConfig.outbounds.size > 1) {
v2rayConfig.outbounds.drop(1)
} else {
emptyList()
}
v2rayConfig.outbounds = ArrayList(chainOutbounds + builtinOutbounds)
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
getCustomLocalDns(v2rayConfig)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
v2rayConfig.stats = null
v2rayConfig.policy = null
}
// Resolve and add to DNS Hosts
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
resolveOutboundDomainsToHosts(v2rayConfig)
}
return v2rayConfig
}
/** Builds full config for normal single-node mode. */
private fun buildNormalConfig(configContext: CoreConfigContext): V2rayConfig {
val config = configContext.selectedProfile
val address = config.server ?: error("Server address is empty")
if (!Utils.isPureIpAddress(address) && !Utils.isValidUrl(address)) {
error("$address is an invalid ip or domain")
}
val v2rayConfig = initV2rayConfig(configContext)
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
getInbounds(v2rayConfig)
getOutbounds(configContext, v2rayConfig)
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
getCustomLocalDns(v2rayConfig)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
v2rayConfig.stats = null
v2rayConfig.policy = null
}
// Resolve and add to DNS Hosts
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
resolveOutboundDomainsToHosts(v2rayConfig)
}
return v2rayConfig
}
/**
* Removes non-essential sections for speedtest use.
*/
private fun postProcessForSpeedtest(v2rayConfig: V2rayConfig) {
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.inbounds.clear()
v2rayConfig.routing.rules.clear()
v2rayConfig.dns = null
v2rayConfig.fakedns = null
v2rayConfig.stats = null
v2rayConfig.policy = null
v2rayConfig.outbounds.forEach { key -> key.mux = null }
}
/** Converts a built config object into a unified result payload. */
private fun toConfigResult(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): ConfigResult {
return ConfigResult(
status = true,
guid = configContext.guid,
content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
)
}
/**
* Initializes V2ray configuration.
*
* This function loads the V2ray configuration from assets or from a cached value.
* It first attempts to use the cached configuration if available, otherwise reads
* the configuration from the "v2ray_config.json" asset file.
*
* @param configContext Runtime context used to access app assets and profile data
* @return V2rayConfig object parsed from the JSON configuration, or null if the configuration is empty
*/
private fun initV2rayConfig(configContext: CoreConfigContext): V2rayConfig {
val context = configContext.context
val assets: String
if (needTun()) {
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
if (TextUtils.isEmpty(assets)) {
error("Missing asset: v2ray_config_with_tun.json")
}
initConfigCacheWithTun = assets
} else {
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
if (TextUtils.isEmpty(assets)) {
error("Missing asset: v2ray_config.json")
}
initConfigCache = assets
}
return JsonUtil.fromJson(assets, V2rayConfig::class.java)
?: error("Failed to parse config template")
}
//endregion
//region some sub function
private fun needTun(): Boolean {
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
}
/**
* Configures the inbound settings for V2ray.
*
* This function sets up the listening ports, sniffing options, and other inbound-related configurations.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getInbounds(v2rayConfig: V2rayConfig) {
val vpn = SettingsManager.isVpnMode()
val useHev = SettingsManager.isUsingHevTun()
val forcedByHev = vpn && useHev
val enableLocalProxy = forcedByHev || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val inbound1 = v2rayConfig.inbounds[0]
if (inbound1.settings == null) {
inbound1.settings = V2rayConfig.InboundBean.InSettingsBean()
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
inbound1.listen = AppConfig.LOOPBACK
}
inbound1.port = socksPort
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
if (socksUsername != null && socksPassword != null) {
inbound1.settings?.auth = "password"
inbound1.settings?.accounts = listOf(
V2rayConfig.InboundBean.InSettingsBean.SocksAccountBean(
user = socksUsername,
pass = socksPassword
)
)
} else {
inbound1.settings?.auth = "noauth"
inbound1.settings?.accounts = null
}
val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
val sniffAllTlsAndHttp =
MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
inbound1.sniffing?.enabled = fakedns || sniffAllTlsAndHttp
inbound1.sniffing?.routeOnly =
MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
if (!sniffAllTlsAndHttp) {
inbound1.sniffing?.destOverride?.clear()
}
if (fakedns) {
inbound1.sniffing?.destOverride?.add("fakedns")
}
if (!Utils.isXray()) {
val inbound2 = JsonUtil.fromJson(JsonUtil.toJson(inbound1), V2rayConfig.InboundBean::class.java)
?: error("Failed to clone inbound template")
inbound2.tag = EConfigType.HTTP.name.lowercase()
inbound2.port = SettingsManager.getHttpPort()
inbound2.protocol = EConfigType.HTTP.name.lowercase()
inbound2.settings?.auth = null
inbound2.settings?.udp = null
v2rayConfig.inbounds.add(inbound2)
}
if (!enableLocalProxy) {
v2rayConfig.inbounds.removeIf { it.protocol == "socks" || it.protocol == "http" }
}
if (needTun()) {
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
inboundTun?.sniffing = inbound1.sniffing
}
}
/**
* Configures the fake DNS settings if enabled.
*
* Adds FakeDNS configuration to v2rayConfig if both local DNS and fake DNS are enabled.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getFakeDns(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
) {
v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
}
}
/**
* Injects custom outbound profiles into the V2ray configuration.
* Uses pre-resolved profiles from the context instead of re-parsing.
*
* @param v2rayConfig The V2ray configuration to modify
* @param customOutbounds Pre-resolved custom outbound profiles (tag -> ProfileItem mapping)
*/
private fun injectCustomOutbounds(v2rayConfig: V2rayConfig, customOutbounds: Map<String, ProfileItem>) {
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
customOutbounds.forEach { (tag, profile) ->
if (tag in existingTags) return@forEach
val outbound = convertProfile2Outbound(profile)
?: error("Could not convert profile '$tag' to outbound")
outbound.tag = tag
v2rayConfig.outbounds.add(outbound)
existingTags.add(tag)
LogUtil.d(AppConfig.TAG, "Injected custom outbound: tag='$tag'")
}
}
/**
* Configures routing settings for V2ray.
*
* Sets up the domain strategy, injects custom outbounds from rulesets, and adds routing rules.
*
* @param configContext Configuration context with custom outbound profiles
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getRouting(configContext: CoreConfigContext, v2rayConfig: V2rayConfig) {
v2rayConfig.routing.domainStrategy =
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
?: "AsIs"
// Inject custom outbound profiles from routing rulesets
injectCustomOutbounds(v2rayConfig, configContext.customOutboundProfiles)
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
getRoutingUserRule(configContext, key, v2rayConfig)
}
}
/**
* Adds a specific ruleset item to the routing configuration.
*
* @param configContext Runtime context used by routing helpers
* @param item The ruleset item to add
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getRoutingUserRule(configContext: CoreConfigContext, item: RulesetItem?, v2rayConfig: V2rayConfig) {
val context = configContext.context
if (item == null || !item.enabled) {
return
}
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), V2rayConfig.RoutingBean.RulesBean::class.java) ?: return
// Replace specific geoip rules with ext versions
rule.ip?.let { ipList ->
val updatedIpList = ArrayList<String>()
ipList.forEach { ip ->
when (ip) {
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
else -> updatedIpList.add(ip)
}
}
rule.ip = updatedIpList
}
if (SettingsManager.canUseProcessRouting()) {
// Convert process package names to UIDs
rule.process?.let { processList ->
if (processList.isNotEmpty()) {
val uids = PackageUidResolver.packageNamesToUids(context, processList)
rule.process = uids.ifEmpty { null }
}
}
} else {
rule.process = null
}
// If the outbound tag is a custom one that failed to inject, fall back to proxy
val outboundTag = rule.outboundTag
if (!outboundTag.isNullOrBlank()
&& outboundTag !in AppConfig.BUILTIN_OUTBOUND_TAGS
&& v2rayConfig.outbounds.none { it.tag == outboundTag }
) {
LogUtil.w(AppConfig.TAG, "Outbound tag '$outboundTag' not found, falling back to '${AppConfig.TAG_PROXY}'")
rule.outboundTag = AppConfig.TAG_PROXY
}
v2rayConfig.routing.rules.add(rule)
}
/**
* Retrieves domain rules for a specific outbound tag.
*
* Searches through all rulesets to find domains targeting the specified tag.
*
* @param tag The outbound tag to search for
* @return ArrayList of domain rules matching the tag
*/
private fun getUserRule2Domain(tag: String): ArrayList<String> {
val domain = ArrayList<String>()
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
key.domain?.forEach {
domain.add(it)
}
}
}
return domain
}
/**
* Retrieves domain rules for custom outbound tags.
*
* Searches through all rulesets to find domains targeting any custom outbound tags.
*
* @return ArrayList of domain rules matching custom outbound tags
*/
private fun getCustomOutboundUserRule2Domain(): ArrayList<String> {
val domain = ArrayList<String>()
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
if (key.enabled && !AppConfig.BUILTIN_OUTBOUND_TAGS.contains(key.outboundTag)
&& !key.domain.isNullOrEmpty()
) {
key.domain?.forEach {
domain.add(it)
}
}
}
return domain
}
/**
* Configures custom local DNS settings.
*
* Sets up DNS inbound, outbound, and routing rules for local DNS resolution.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getCustomLocalDns(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
val proxyDomain = getUserRule2Domain(AppConfig.TAG_PROXY)
val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
val finalDomain = geositeCn.plus(proxyDomain).plus(directDomain).distinct()
// fakedns with all domains to make it always top priority
v2rayConfig.dns?.servers?.add(
0,
V2rayConfig.DnsBean.ServersBean(
address = "fakedns",
domains = finalDomain
)
)
}
if (SettingsManager.isVpnMode()) {
if (SettingsManager.isUsingHevTun()) {
//hev-socks5-tunnel dns routing
v2rayConfig.routing.rules.add(
0, V2rayConfig.RoutingBean.RulesBean(
inboundTag = arrayListOf("socks"),
outboundTag = "dns-out",
port = "53",
)
)
} else {
v2rayConfig.routing.rules.add(
0, V2rayConfig.RoutingBean.RulesBean(
inboundTag = arrayListOf("tun"),
outboundTag = "dns-out",
port = "53",
)
)
}
}
// DNS outbound
if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
v2rayConfig.outbounds.add(
V2rayConfig.OutboundBean(
protocol = "dns",
tag = "dns-out",
settings = null,
streamSettings = null,
mux = null
)
)
}
}
/**
* Configures the DNS settings for V2ray.
*
* Sets up DNS servers, hosts, and routing rules for DNS resolution.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getDns(v2rayConfig: V2rayConfig) {
val hosts = mutableMapOf<String, Any>()
val servers = ArrayList<Any>()
//remote Dns
val remoteDns = SettingsManager.getRemoteDnsServers()
val proxyDomain = (getUserRule2Domain(AppConfig.TAG_PROXY) + getCustomOutboundUserRule2Domain()).distinct()
remoteDns.forEach {
servers.add(it)
}
if (proxyDomain.isNotEmpty()) {
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = remoteDns.first(),
domains = proxyDomain,
)
)
}
// domestic DNS
val domesticDns = SettingsManager.getDomesticDnsServers()
val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
val cnRegionFilter = { domain: String ->
domain.startsWith("geosite:") && (domain.endsWith("-cn") || domain.endsWith("@cn"))
|| domain == AppConfig.GEOSITE_CN
}
val finalDirectDomain = if (isCnRoutingMode) directDomain.filterNot {
cnRegionFilter(it)
} else directDomain
val domesticDnsTags = mutableListOf<String>()
domesticDns.forEachIndexed { index, element ->
val tag = AppConfig.TAG_DOMESTIC_DNS + index
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = element,
domains = finalDirectDomain,
skipFallback = true,
tag = tag
)
)
domesticDnsTags.add(tag)
}
if (isCnRoutingMode) {
val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
val cnRegionDomain = directDomain.filter { cnRegionFilter(it) }
domesticDns.forEachIndexed { index, element ->
val geositeCnDnsTag = AppConfig.TAG_DOMESTIC_DNS + index + "_cn_expect"
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = element,
domains = cnRegionDomain,
expectIPs = geoipCn,
skipFallback = true,
tag = geositeCnDnsTag
)
)
domesticDnsTags.add(geositeCnDnsTag)
}
}
//block dns
val blkDomain = getUserRule2Domain(AppConfig.TAG_BLOCKED)
if (blkDomain.isNotEmpty()) {
hosts.putAll(blkDomain.map { it to AppConfig.LOOPBACK })
}
// hardcode googleapi rule to fix play store problems
hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
// hardcode popular Android Private DNS rule to fix localhost DNS problem
hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
//User DNS hosts
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
if (userHosts.isNotNullEmpty()) {
val userHostsMap = userHosts?.split(",")
?.filter { it.isNotEmpty() }
?.filter { it.contains(":") }
?.associate { it.split(":").let { (k, v) -> k to v } }
if (userHostsMap != null) hosts.putAll(userHostsMap)
}
// DNS dns
v2rayConfig.dns = V2rayConfig.DnsBean(
servers = servers,
hosts = hosts,
tag = AppConfig.TAG_DNS,
enableParallelQuery = if ((domesticDns.size + remoteDns.size) > 2) true else null
)
// DNS routing
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
outboundTag = AppConfig.TAG_DIRECT,
inboundTag = domesticDnsTags,
domain = null
)
)
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
outboundTag = AppConfig.TAG_PROXY,
inboundTag = arrayListOf(AppConfig.TAG_DNS),
domain = null
)
)
}
//endregion
//region outbound related functions
/**
* Configures the primary outbound connection.
*
* Converts the profile to an outbound configuration and applies global settings.
*
* @param configContext Runtime context containing the selected profile
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getOutbounds(configContext: CoreConfigContext, v2rayConfig: V2rayConfig) {
val outbound = convertProfile2Outbound(configContext.selectedProfile)
?: error("Failed to convert selected profile to outbound")
if (v2rayConfig.outbounds.isNotEmpty()) {
v2rayConfig.outbounds[0] = outbound
} else {
v2rayConfig.outbounds.add(outbound)
}
}
/**
* Configures load balancing settings for the V2ray configuration.
*
* @param configContext Runtime context containing policy group settings
* @param v2rayConfig The V2ray configuration object to be modified with balancing settings
*/
private fun getBalance(configContext: CoreConfigContext, v2rayConfig: V2rayConfig) {
val config = configContext.selectedProfile
v2rayConfig.routing.rules.forEach { rule ->
if (rule.outboundTag == AppConfig.TAG_PROXY) {
rule.outboundTag = null
rule.balancerTag = AppConfig.TAG_BALANCER
}
}
val lstSelector = listOf("proxy-")
when (config.policyGroupType) {
// Least Ping goto else
"1" -> {
// Least Load
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastLoad"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
subjectSelector = lstSelector,
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
interval = "5m",
sampling = 2,
timeout = "30s"
)
)
}
"2" -> {
// Random
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "random"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
}
"3" -> {
// Round Robin
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "roundRobin"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
}
else -> {
// Default: Least Ping
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastPing"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
subjectSelector = lstSelector,
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
probeInterval = "3m",
enableConcurrency = true
)
}
}
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
ip = arrayListOf("0.0.0.0/0", "::/0"),
balancerTag = AppConfig.TAG_BALANCER,
)
)
} else {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
network = "tcp,udp",
balancerTag = AppConfig.TAG_BALANCER,
)
)
}
}
/**
* Resolves domain names to IP addresses in outbound connections.
*
* Pre-resolves domains to improve connection speed and reliability.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
val proxyOutboundList = v2rayConfig.getAllProxyOutbound()
val dns = v2rayConfig.dns ?: return
val newHosts = dns.hosts?.toMutableMap() ?: mutableMapOf()
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true
for (item in proxyOutboundList) {
val domain = item.getServerAddress()
if (domain.isNullOrEmpty()) continue
if (newHosts.containsKey(domain)) {
item.ensureSockopt().domainStrategy = "UseIP"
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
prioritizeIPv6 = preferIpv6,
interleave = 2
)
continue
}
val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6)
if (resolvedIps.isNullOrEmpty()) continue
item.ensureSockopt().domainStrategy = "UseIP"
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
prioritizeIPv6 = preferIpv6,
interleave = 2
)
newHosts[domain] = if (resolvedIps.size == 1) {
resolvedIps[0]
} else {
resolvedIps
}
}
dns.hosts = newHosts
}
/**
* Converts a profile item to an outbound configuration.
*
* Delegates to [CoreOutboundBuilder] which owns all per-protocol
* conversion logic, keeping this manager focused on config orchestration.
*
* @param profileItem The profile item to convert
* @return OutboundBean configuration for the profile, or null if not supported
*/
private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
return CoreOutboundBuilder.convert(profileItem)
}
//endregion
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.handler
package com.v2ray.ang.core
import android.content.Context
import com.v2ray.ang.AppConfig
@@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
* Thread-safe singleton wrapper for Libv2ray native methods.
* Provides initialization protection and unified API for V2Ray core operations.
*/
object V2RayNativeManager {
object CoreNativeManager {
private val initialized = AtomicBoolean(false)
/**
@@ -43,6 +43,15 @@ object V2RayNativeManager {
}
}
fun reconcileBrowserDialer(dialerAddr: String) {
try {
Libv2ray.reconcileBrowserDialer(dialerAddr)
LogUtil.i(AppConfig.TAG, "Browser dialer reconciled successfully with address: $dialerAddr")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to reconcile browser dialer with address: $dialerAddr", e)
}
}
/**
* Get V2Ray core version.
@@ -88,4 +97,4 @@ object V2RayNativeManager {
throw e
}
}
}
}
@@ -0,0 +1,688 @@
package com.v2ray.ang.core
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
/**
* Centralizes ProfileItem -> OutboundBean conversion.
* Most protocol builders mirror the previous *Fmt.toOutbound behavior.
*/
object CoreOutboundBuilder {
/** Dispatches a profile to protocol-specific outbound builder. */
fun convert(profileItem: ProfileItem): OutboundBean? {
val outbound = when (profileItem.configType) {
EConfigType.VMESS -> toOutboundVmess(profileItem)
EConfigType.SHADOWSOCKS -> toOutboundShadowsocks(profileItem)
EConfigType.SOCKS -> toOutboundSocks(profileItem)
EConfigType.VLESS -> toOutboundVless(profileItem)
EConfigType.TROJAN -> toOutboundTrojan(profileItem)
EConfigType.WIREGUARD -> toOutboundWireguard(profileItem)
EConfigType.HYSTERIA2 -> toOutboundHysteria2(profileItem)
EConfigType.HTTP -> toOutboundHttp(profileItem)
else -> null
}
outbound ?: return null
val ret = updateOutboundWithGlobalSettings(outbound)
if (!ret) return null
return outbound
}
/** Applies global outbound options (mux, protocol-specific tweaks, etc.). */
private fun updateOutboundWithGlobalSettings(outbound: OutboundBean): Boolean {
try {
var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
val protocol = outbound.protocol
if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
muxEnabled = false
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
muxEnabled = false
}
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?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
outbound.mux?.concurrency = -1
}
} else {
outbound.mux?.enabled = false
outbound.mux?.concurrency = -1
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
return false
}
return true
}
/** Creates an initial outbound template for a protocol type. */
fun createInitOutbound(configType: EConfigType): OutboundBean? {
return when (configType) {
EConfigType.VMESS,
EConfigType.VLESS ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
vnext = listOf(
OutboundBean.OutSettingsBean.VnextBean(
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
)
)
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.SHADOWSOCKS,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.TROJAN ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.WIREGUARD ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
secretKey = "",
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
)
)
EConfigType.HYSTERIA,
EConfigType.HYSTERIA2 ->
return OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
servers = null
),
streamSettings = OutboundBean.StreamSettingsBean()
)
else -> null
}
}
// ── Per-protocol builders — implementations are identical to each *Fmt.toOutbound ──
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].security = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].encryption = profileItem.method
vnext.users[0].flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.method = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
val rawAddresses = profileItem.localAddress
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.ifEmpty { null }
?: listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
val addresses = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
rawAddresses
} else {
val ipv4Addresses = rawAddresses.filter { !it.contains(":") }
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
}
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = addresses
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.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
return outboundBean
}
private fun toOutboundHysteria2(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.HYSTERIA2) ?: return null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun createTcpHttpRequest(
host: String?,
path: String?
): OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean {
val requestString =
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
val request = JsonUtil.fromJson(
requestString,
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
) ?: OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
val parsedHost = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
request.headers.Host = parsedHost.ifEmpty { null }
request.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("/") }
return request
}
/**
* Configures transport settings for an outbound connection.
*
* Sets up protocol-specific transport options based on the profile settings.
*
* @param streamSettings The stream settings to configure
* @param profileItem The profile containing transport configuration
* @return The Server Name Indication (SNI) value to use, or null if not applicable
*/
fun populateTransportSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem): String? {
val transport = profileItem.network.orEmpty()
val headerType = profileItem.headerType
val host = profileItem.host
val path = profileItem.path
val seed = profileItem.seed
// val quicSecurity = profileItem.quicSecurity
// val key = profileItem.quicKey
val mode = profileItem.mode
val serviceName = profileItem.serviceName
val authority = profileItem.authority
val xhttpMode = profileItem.xhttpMode
val xhttpExtra = profileItem.xhttpExtra
val finalMask = profileItem.finalMask
var sni: String? = null
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
when (streamSettings.network) {
NetworkType.TCP.type -> {
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
val requestObj = createTcpHttpRequest(host, path)
tcpSetting.header.request = requestObj
sni = requestObj.headers.Host?.getOrNull(0)
} else {
tcpSetting.header.type = "none"
sni = host
}
streamSettings.tcpSettings = tcpSetting
}
NetworkType.KCP.type -> {
val kcpSetting = OutboundBean.StreamSettingsBean.KcpSettingsBean()
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
profileItem.kcpTti?.let { kcpSetting.tti = it }
streamSettings.kcpSettings = kcpSetting
val udpMaskList = mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
if (!headerType.isNullOrEmpty() && headerType != "none") {
val kcpHeaderType = when {
headerType == "wechat-video" -> "header-wechat"
else -> "header-$headerType"
}
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = kcpHeaderType,
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
domain = host
)
} else {
null
}
)
)
}
if (seed.isNullOrEmpty()) {
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-original"
)
)
} else {
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-aes128gcm",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = seed
)
)
)
}
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
udp = udpMaskList.toList()
)
}
NetworkType.WS.type -> {
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
wssetting.headers.Host = host.orEmpty()
sni = host
wssetting.path = path ?: "/"
streamSettings.wsSettings = wssetting
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
httpupgradeSetting.host = host.orEmpty()
sni = host
httpupgradeSetting.path = path ?: "/"
streamSettings.httpupgradeSettings = httpupgradeSetting
}
NetworkType.XHTTP.type -> {
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
xhttpSetting.host = host.orEmpty()
sni = host
xhttpSetting.path = path ?: "/"
xhttpSetting.mode = xhttpMode
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
streamSettings.xhttpSettings = xhttpSetting
}
NetworkType.H2.type, NetworkType.HTTP.type -> {
streamSettings.network = NetworkType.H2.type
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
sni = h2Setting.host.getOrNull(0)
h2Setting.path = path ?: "/"
streamSettings.httpSettings = h2Setting
}
// "quic" -> {
// val quicsetting = QuicSettingBean()
// quicsetting.security = quicSecurity ?: "none"
// quicsetting.key = key.orEmpty()
// quicsetting.header.type = headerType ?: "none"
// quicSettings = quicsetting
// }
NetworkType.GRPC.type -> {
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
grpcSetting.multiMode = mode == "multi"
grpcSetting.serviceName = serviceName.orEmpty()
grpcSetting.authority = authority.orEmpty()
grpcSetting.idle_timeout = 60
grpcSetting.health_check_timeout = 20
sni = authority
streamSettings.grpcSettings = grpcSetting
}
NetworkType.HYSTERIA.type -> {
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
version = 2,
auth = profileItem.password.orEmpty(),
)
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
)
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
if (profileItem.portHopping.isNotNullEmpty()) {
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
"30"
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
"30"
} else {
rawInterval
}
} else {
val parts = rawInterval.split('-')
if (parts.size == 2) {
val start = parts[0].trim().toIntOrNull()
val end = parts[1].trim().toIntOrNull()
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
"$minStart-$minEnd"
} else {
"30"
}
} else {
"30"
}
}
}
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
ports = profileItem.portHopping,
interval = interval
)
}
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
quicParams = quicParams
)
if (profileItem.obfsPassword.isNotNullEmpty()) {
finalmask.udp = listOf(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "salamander",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = profileItem.obfsPassword.orEmpty()
)
)
)
}
streamSettings.hysteriaSettings = hysteriaSetting
streamSettings.finalmask = finalmask
}
}
finalMask?.let {
val parsedFinalMask = JsonUtil.parseString(finalMask)
if (parsedFinalMask != null) {
streamSettings.finalmask = parsedFinalMask
} else {
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
}
}
return sni
}
/**
* Configures TLS or REALITY security settings for an outbound connection.
*
* Sets up security-related parameters like certificates, fingerprints, and SNI.
*
* @param streamSettings The stream settings to configure
* @param profileItem The profile containing security configuration
* @param sniExt An external SNI value to use if the profile doesn't specify one
*/
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
val streamSecurity = profileItem.security.orEmpty()
val allowInsecure = profileItem.insecure == true
val sni = if (profileItem.sni.isNullOrEmpty()) {
when {
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
else -> sniExt
}
} else {
profileItem.sni
}
streamSettings.security = streamSecurity.nullIfBlank()
if (streamSettings.security == null) return
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
allowInsecure = allowInsecure,
serverName = sni.nullIfBlank(),
fingerprint = profileItem.fingerPrint.nullIfBlank(),
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
echConfigList = profileItem.echConfigList.nullIfBlank(),
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
publicKey = profileItem.publicKey.nullIfBlank(),
shortId = profileItem.shortId.nullIfBlank(),
spiderX = profileItem.spiderX.nullIfBlank(),
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
)
if (streamSettings.security == AppConfig.TLS) {
streamSettings.tlsSettings = tlsSetting
streamSettings.realitySettings = null
} else if (streamSettings.security == AppConfig.REALITY) {
streamSettings.tlsSettings = null
streamSettings.realitySettings = tlsSetting
}
if (profileItem.finalMask.isNullOrEmpty()) {
updateOutboundFragment(streamSettings)
}
}
/**
* Updates the outbound with fragment settings for traffic optimization.
*
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
*
* @param streamSettings The streamSettings object to be modified
* @return true if fragment configuration was successful, false otherwise
*/
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
try {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
return true
}
if (streamSettings.security != AppConfig.TLS
&& streamSettings.security != AppConfig.REALITY
) {
return true
}
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
return true
}
var packets =
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
if (streamSettings.security == AppConfig.REALITY
&& packets == "tlshello"
) {
packets = "1-3"
} else if (streamSettings.security == AppConfig.TLS
&& packets != "tlshello"
) {
packets = "tlshello"
}
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "fragment",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
packets = packets,
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
?: "50-100",
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
?: "10-20"
)
)
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "noise",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
noise = listOf(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
rand = "10-20",
delay = "10-16",
)
)
)
)
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
} ?: JsonObject()
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
val current = finalMaskObj.get(scope)
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
return
}
val newArray = JsonArray()
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
if (current != null && current.isJsonArray) {
current.asJsonArray.forEach { newArray.add(it) }
}
finalMaskObj.add(scope, newArray)
}
prependMask("tcp", fragmentMask)
prependMask("udp", noiseMask)
streamSettings.finalmask = finalMaskObj
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
return false
}
return true
}
private fun getServerAddress(profileItem: ProfileItem): String {
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
return profileItem.server.orEmpty()
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
return domain
}
//Resolve and replace domain
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
if (resolvedIps.isNullOrEmpty()) {
return domain
}
return resolvedIps.first()
}
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.handler
package com.v2ray.ang.core
import android.app.Service
import android.content.BroadcastReceiver
@@ -16,8 +16,15 @@ import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.service.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.service.CoreProxyOnlyService
import com.v2ray.ang.service.CoreVpnService
import com.v2ray.ang.service.DialerNativeService
import com.v2ray.ang.service.DialerWebviewService
import com.v2ray.ang.service.IDialerService
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
@@ -30,18 +37,19 @@ import libv2ray.ProcessFinder
import java.lang.ref.SoftReference
import java.net.InetSocketAddress
object V2RayServiceManager {
object CoreServiceManager {
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
private val coreController: CoreController = CoreNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null
private var processFinder: XrayProcessFinder? = null
private var browserDialer: IDialerService? = null
var serviceControl: SoftReference<ServiceControl>? = null
set(value) {
field = value
val service = value?.get()?.getService()
V2RayNativeManager.initCoreEnv(service)
CoreNativeManager.initCoreEnv(service)
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
processFinder = XrayProcessFinder(service)
coreController.registerProcessFinder(processFinder)
@@ -58,7 +66,13 @@ object V2RayServiceManager {
context.toast(R.string.app_tile_first_use)
return false
}
startContextService(context)
try {
startContextService(context)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
context.toast(e.message ?: e.javaClass.simpleName)
return false
}
return true
}
@@ -74,7 +88,12 @@ object V2RayServiceManager {
MmkvManager.setSelectServer(guid)
}
startContextService(context)
try {
startContextService(context)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
context.toast(e.message ?: e.javaClass.simpleName)
}
}
/**
@@ -102,7 +121,11 @@ object V2RayServiceManager {
* Starts the context service for V2Ray.
* Chooses between VPN service or Proxy-only service based on user settings.
* @param context The context from which the service is started.
* @throws IllegalStateException if the core is already running, no server is selected,
* server config cannot be decoded, or server configuration is invalid.
* @throws Exception if the foreground service fails to start.
*/
@Throws(Exception::class)
private fun startContextService(context: Context) {
if (coreController.isRunning) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
@@ -110,30 +133,32 @@ object V2RayServiceManager {
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return
}
?: run {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
error(context.getString(R.string.app_tile_first_use))
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return
}
?: run {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
error(context.getString(R.string.toast_config_file_invalid))
}
if (config.configType != EConfigType.CUSTOM
&& config.configType != EConfigType.POLICYGROUP
&& config.configType != EConfigType.PROXYCHAIN
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
return
error(context.getString(R.string.toast_config_file_invalid))
}
// refresh socks port when enabled dynamic socks port
SettingsManager.refreshRuntimeSocksPort()
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) return
// if (!result.status) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
context.toast(R.string.toast_warning_pref_proxysharing_short)
@@ -144,17 +169,13 @@ object V2RayServiceManager {
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, V2RayVpnService::class.java)
Intent(context.applicationContext, CoreVpnService::class.java)
} else {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
}
ContextCompat.startForegroundService(context, intent)
}
/**
@@ -174,67 +195,70 @@ object V2RayServiceManager {
return false
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
try {
doStartCoreLoop(service, vpnInterface)
return true
} catch (e: Exception) {
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
LogUtil.e(AppConfig.TAG, "StartCore-Manager: $message", e)
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
NotificationManager.cancelNotification()
return false
}
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return false
}
@Throws(Exception::class)
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
val guid = MmkvManager.getSelectServer() ?: error("No server selected")
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
val result = CoreConfigManager.getV2rayConfig(service, guid)
LogUtil.d(AppConfig.TAG, result.content)
if (!result.status) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
return false
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
}
try {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
return false
}
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
currentConfig = config
var tunFd = vpnInterface?.fd ?: 0
val dialerAddr = if (currentConfig?.browserDialerMode.isNullOrEmpty()) {
""
} else {
"127.0.0.1:${Utils.findRandomFreePort()}"
}
if (SettingsManager.isUsingHevTun()) {
tunFd = 0
}
try {
NotificationManager.showNotification(currentConfig)
coreController.startLoop(result.content, tunFd)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
return false
NotificationManager.showNotification(currentConfig)
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
coreController.startLoop(result.content, tunFd)
if (!coreController.isRunning) {
error("Core failed to start")
}
if (coreController.isRunning == false) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
NotificationManager.cancelNotification()
return false
if (browserDialer != null) {
browserDialer!!.stop()
browserDialer = null
}
if (config.browserDialerMode == "OkHttp") {
browserDialer = DialerNativeService()
browserDialer!!.start(service, dialerAddr)
} else if (config.browserDialerMode == "WebView") {
browserDialer = DialerWebviewService()
browserDialer!!.start(service, dialerAddr)
}
try {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.startSpeedNotification(currentConfig)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
return false
}
return true
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.startSpeedNotification(currentConfig)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
}
/**
@@ -255,6 +279,13 @@ object V2RayServiceManager {
}
}
// Close existing browser dialer
CoreNativeManager.reconcileBrowserDialer("")
if (browserDialer != null) {
browserDialer!!.stop()
browserDialer = null
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
NotificationManager.cancelNotification()
@@ -401,7 +432,7 @@ object V2RayServiceManager {
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
uid
} catch (e: Exception) {
} catch (_: Exception) {
-1L
}
}
@@ -4,5 +4,6 @@ data class ConfigResult(
var status: Boolean,
var guid: String? = null,
var content: String = "",
var errorMessage: String = "",
)
@@ -0,0 +1,16 @@
package com.v2ray.ang.dto
import android.content.Context
import com.v2ray.ang.enums.CoreResolvedType
/**
* Runtime context produced by the builder and consumed by CoreConfigManager.
*/
data class CoreConfigContext(
val context: Context,
val guid: String,
val selectedProfile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
val customOutboundProfiles: Map<String, ProfileItem> = emptyMap(),
)
@@ -29,6 +29,9 @@ data class ProfileItem(
var host: String? = null,
var path: String? = null,
var seed: String? = null,
var kcpMtu: Int? = null,
var kcpTti: Int? = null,
var quicSecurity: String? = null,
var quicKey: String? = null,
var mode: String? = null,
@@ -43,7 +46,6 @@ data class ProfileItem(
var fingerPrint: String? = null,
var insecure: Boolean? = null,
var echConfigList: String? = null,
var echForceQuery: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null,
@@ -67,6 +69,9 @@ data class ProfileItem(
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
var proxyChainProfiles: String? = null,
var browserDialerMode: String? = null,
) {
companion object {
@@ -102,6 +107,8 @@ data class ProfileItem(
&& this.host == obj.host
&& this.path == obj.path
&& this.seed == obj.seed
&& this.kcpMtu == obj.kcpMtu
&& this.kcpTti == obj.kcpTti
&& this.quicSecurity == obj.quicSecurity
&& this.quicKey == obj.quicKey
&& this.mode == obj.mode
@@ -125,6 +132,7 @@ data class ProfileItem(
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinnedCA256 == obj.pinnedCA256
&& this.proxyChainProfiles == obj.proxyChainProfiles
)
}
}
@@ -0,0 +1,14 @@
package com.v2ray.ang.dto
sealed class RealPingEvent {
/** Periodic progress update while the batch is still running. */
data class Progress(val text: String) : RealPingEvent()
/** A single server result is available. */
data class Result(val guid: String, val delayMillis: Long) : RealPingEvent()
/** The entire batch has finished or been cancelled. */
data class Finish(val status: String) : RealPingEvent()
}
@@ -7,7 +7,7 @@ data class SubscriptionItem(
val addedTime: Long = System.currentTimeMillis(),
var lastUpdated: Long = -1,
var autoUpdate: Boolean = false,
val updateInterval: Int? = null,
var updateInterval: Long = 1440, // in minutes, default to 24 hours
var prevProfile: String? = null,
var nextProfile: String? = null,
var filter: String? = null,
@@ -181,7 +181,7 @@ data class V2rayConfig(
@SerializedName("Accept-Encoding")
val acceptEncoding: List<String>? = null,
val Connection: List<String>? = null,
val Pragma: String? = null
val Pragma: Any? = null
)
}
}
@@ -256,7 +256,6 @@ data class V2rayConfig(
val disableSystemRoot: Boolean? = null,
val enableSessionResumption: Boolean? = null,
var echConfigList: String? = null,
var echForceQuery: String? = null,
var pinnedPeerCertSha256: String? = null,
// REALITY settings
val show: Boolean = false,
@@ -345,19 +344,19 @@ data class V2rayConfig(
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) {
return settings?.vnext?.first()?.address
return settings?.vnext?.firstOrNull()?.address ?: settings?.address as? String
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
) {
return settings?.servers?.first()?.address
return settings?.servers?.firstOrNull()?.address
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
return settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.address as String?
return settings?.address as? String
}
return null
}
@@ -366,15 +365,15 @@ data class V2rayConfig(
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) {
return settings?.vnext?.first()?.port
return settings?.vnext?.firstOrNull()?.port ?: settings?.port
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
) {
return settings?.servers?.first()?.port
return settings?.servers?.firstOrNull()?.port
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
return settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toInt()
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
@@ -402,6 +401,7 @@ data class V2rayConfig(
val clientIp: String? = null,
val disableCache: Boolean? = null,
val queryStrategy: String? = null,
val enableParallelQuery: Boolean? = null,
val tag: String? = null
) {
data class ServersBean(
@@ -0,0 +1,9 @@
package com.v2ray.ang.enums
/** Runtime type used during config assembly only. */
enum class CoreResolvedType {
NORMAL,
POLICYGROUP,
PROXYCHAIN,
CUSTOM,
}
@@ -15,7 +15,8 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
HYSTERIA2(9, AppConfig.HYSTERIA2),
HYSTERIA(900, AppConfig.HYSTERIA),
HTTP(10, AppConfig.HTTP),
POLICYGROUP(101, AppConfig.CUSTOM);
POLICYGROUP(101, AppConfig.CUSTOM),
PROXYCHAIN(102, AppConfig.CUSTOM);
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
@@ -0,0 +1,22 @@
package com.v2ray.ang.enums
/**
* Enum defining different notification channels.
* Each channel has a unique channelId, notificationId, and display name.
*/
enum class NotificationChannelType(
val channelId: String,
val channelName: String,
val notificationId: Int
) {
SUBSCRIPTION_UPDATE(
channelId = "subscription_update_channel",
channelName = "Subscription Update Service",
notificationId = 13
),
CORE_TEST(
channelId = "core_test_channel",
channelName = "Core Test Service",
notificationId = 12
)
}
@@ -20,7 +20,7 @@ object CustomFmt : FmtBase() {
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
config.server = outbound?.getServerAddress()
config.serverPort = outbound?.getServerPort().toString()
config.serverPort = outbound?.getServerPort()?.toString()
return config
}
@@ -4,7 +4,6 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -60,6 +59,8 @@ open class FmtBase {
config.path = queryParam["path"]
config.seed = queryParam["seed"]
config.kcpMtu = queryParam["mtu"]?.toIntOrNull()
config.kcpTti = queryParam["tti"]?.toIntOrNull()
config.quicSecurity = queryParam["quicSecurity"]
config.quicKey = queryParam["key"]
config.mode = queryParam["mode"]
@@ -112,6 +113,8 @@ open class FmtBase {
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
config.kcpMtu?.let { dicQuery["mtu"] = it.toString() }
config.kcpTti?.let { dicQuery["tti"] = it.toString() }
// Add two keys for compatibility: "insecure" and "allowInsecure"
if (config.security == AppConfig.TLS) {
val insecureFlag = if (config.insecure == true) "1" else "0"
@@ -168,21 +171,4 @@ open class FmtBase {
return dicQuery
}
fun getServerAddress(profileItem: ProfileItem): String {
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
return profileItem.server.orEmpty()
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
return domain
}
//Resolve and replace domain
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
if (resolvedIps.isNullOrEmpty()) {
return domain
}
return resolvedIps.first()
}
}
@@ -1,32 +0,0 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
object HttpFmt : FmtBase() {
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
}
@@ -2,14 +2,12 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -110,32 +108,4 @@ object Hysteria2Fmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) ?: return null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -2,11 +2,9 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -124,31 +122,4 @@ object ShadowsocksFmt : FmtBase() {
return toUri(config, Utils.encode(pw, true), null)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.method = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -1,11 +1,9 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -53,27 +51,4 @@ object SocksFmt : FmtBase() {
return toUri(config, Utils.encode(pw, true), null)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
}
@@ -2,12 +2,10 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -53,31 +51,4 @@ object TrojanFmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -2,11 +2,9 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -50,31 +48,4 @@ object VlessFmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].encryption = profileItem.method
vnext.users[0].flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -3,14 +3,12 @@ package com.v2ray.ang.fmt
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
@@ -174,31 +172,5 @@ object VmessFmt : FmtBase() {
return config
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].security = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -1,14 +1,11 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -31,7 +28,7 @@ object WireguardFmt : FmtBase() {
config.serverPort = uri.port.toString()
config.secretKey = uri.userInfo.orEmpty()
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.localAddress = queryParam["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
@@ -82,7 +79,7 @@ object WireguardFmt : FmtBase() {
config.secretKey = interfaceParams["privatekey"].orEmpty()
config.remarks = System.currentTimeMillis().toString()
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
@@ -100,29 +97,6 @@ object WireguardFmt : FmtBase() {
return config
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.WIREGUARD)
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
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.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
return outboundBean
}
/**
* Converts a ProfileItem object to a URI string.
@@ -6,6 +6,7 @@ import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
@@ -111,7 +112,7 @@ object AngConfigManager {
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
try {
if (guid == null) return -1
val result = V2rayConfigManager.getV2rayConfig(context, guid)
val result = CoreConfigManager.getV2rayConfig(context, guid)
if (result.status) {
Utils.setClipboard(context, result.content)
} else {
@@ -136,15 +137,12 @@ object AngConfigManager {
return config.configType.protocolScheme + when (config.configType) {
EConfigType.VMESS -> VmessFmt.toUri(config)
EConfigType.CUSTOM -> ""
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
EConfigType.SOCKS -> SocksFmt.toUri(config)
EConfigType.HTTP -> ""
EConfigType.VLESS -> VlessFmt.toUri(config)
EConfigType.TROJAN -> TrojanFmt.toUri(config)
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
EConfigType.POLICYGROUP -> ""
else -> {}
}
} catch (e: Exception) {
@@ -679,6 +679,7 @@ object MmkvManager {
return settingsStorage.decodeStringSet(key)
}
/**
* Encodes the start on boot setting.
*
@@ -13,6 +13,7 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.ui.MainActivity
@@ -44,7 +45,7 @@ object NotificationManager {
*/
fun startSpeedNotification(currentConfig: ProfileItem?) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags()
@@ -67,15 +68,15 @@ object NotificationManager {
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
val up = CoreServiceManager.queryStats(it, AppConfig.UPLINK)
val down = CoreServiceManager.queryStats(it, AppConfig.DOWNLINK)
if (up + down > 0) {
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
proxyTotal += up + down
}
}
val directUplink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
val directUplink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
if (!zeroSpeed || !lastZeroSpeed) {
if (proxyTotal == 0L) {
@@ -251,6 +252,6 @@ object NotificationManager {
* @return The service instance.
*/
private fun getService(): Service? {
return V2RayServiceManager.serviceControl?.get()?.getService()
return CoreServiceManager.serviceControl?.get()?.getService()
}
}
@@ -252,12 +252,7 @@ object SettingsManager {
* it creates a new default subscription to ensure that ungroup
**/
fun removeSubscriptionWithDefault(subid: String) {
// val subsList = decodeSubsList()
// if (subsList.size == 1 && subsList.first() == DEFAULT_SUBSCRIPTION_ID) {
// LogUtil.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
// return
// }
SubscriptionUpdater.cancelOne(subId = subid)
// Remove the subscription
removeSubscription(subid)
@@ -398,6 +393,15 @@ object SettingsManager {
}
}
/**
* Get real ping concurrency.
* @return The number of concurrent real-ping tests (clamped to 1..64).
*/
fun getRealPingConcurrency(): Int {
val value = MmkvManager.decodeSettingsString(AppConfig.PREF_REAL_PING_CONCURRENCY)?.toIntOrNull() ?: 16
return value.coerceIn(1, 128)
}
/**
* Get the locale.
* @return The locale.
@@ -499,7 +503,6 @@ object SettingsManager {
ensureDefaultValue(AppConfig.PREF_MODE, VPN)
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
ensureDefaultValue(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
@@ -1,9 +1,6 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.SystemClock
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
@@ -90,6 +87,7 @@ object SpeedtestManager {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
if (httpPort == 0) return null
val content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: return null
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
@@ -1,62 +1,192 @@
package com.v2ray.ang.handler
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.multiprocess.RemoteWorkManager
import androidx.work.workDataOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.NotificationHelper
import java.util.concurrent.TimeUnit
object SubscriptionUpdater {
// -------------------------------------------------------------------------
// Public API — the only methods external callers should ever use
// -------------------------------------------------------------------------
/**
* Sync all subscription tasks with current settings.
*
* Startup/boot callers should use the default mode so existing periodic work is kept.
* Use forceReschedule=true only when the next run time needs to be recalculated from
* the latest persisted subscription state (for example after a manual refresh).
* Call from: MainActivity.onCreate(), BootReceiver.onReceive().
*/
fun sync(
context: Context = AngApplication.application,
forceReschedule: Boolean = false
) {
val existingWorkPolicy =
if (forceReschedule) {
ExistingPeriodicWorkPolicy.REPLACE
} else {
ExistingPeriodicWorkPolicy.KEEP
}
MmkvManager.decodeSubscriptions().forEach { sub ->
scheduleOne(
context = context,
subId = sub.guid,
shouldRun = sub.subscription.autoUpdate,
existingWorkPolicy = existingWorkPolicy
)
}
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: sync complete forceReschedule=$forceReschedule"
)
}
/**
* Sync a single subscription's task.
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
*/
fun syncOne(context: Context = AngApplication.application, subId: String) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
scheduleOne(
context = context,
subId = subId,
shouldRun = subItem.autoUpdate,
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
)
}
/**
* Cancel the auto-update task for a single subscription.
* Call from: when a subscription is deleted.
*/
fun cancelOne(context: Context = AngApplication.application, subId: String) {
RemoteWorkManager.getInstance(context)
.cancelUniqueWork(taskName(subId))
}
// -------------------------------------------------------------------------
// Internal scheduling logic
// -------------------------------------------------------------------------
private fun taskName(subId: String) = "${AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME}_$subId"
private fun scheduleOne(
context: Context,
subId: String,
shouldRun: Boolean,
existingWorkPolicy: ExistingPeriodicWorkPolicy
) {
val rw = RemoteWorkManager.getInstance(context)
if (!shouldRun) {
rw.cancelUniqueWork(taskName(subId))
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for $subId")
return
}
val subItem = MmkvManager.decodeSubscription(subId) ?: return
val intervalMinutes = maxOf(
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
subItem.updateInterval
)
// Base initial delay on the last successful update time persisted in subscription.
val lastUpdated = subItem.lastUpdated
val intervalMillis = intervalMinutes * 60 * 1000L
val now = System.currentTimeMillis()
val initialDelayMillis = if (lastUpdated <= 0L) {
0L
} else {
maxOf(0L, lastUpdated + intervalMillis - now)
}
val request = PeriodicWorkRequestBuilder<UpdateTask>(intervalMinutes, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(workDataOf(KEY_SUB_ID to subId))
.setInitialDelay(initialDelayMillis, TimeUnit.MILLISECONDS)
.addTag(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
.build()
rw.enqueueUniquePeriodicWork(
taskName(subId),
existingWorkPolicy,
request
)
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
)
}
// -------------------------------------------------------------------------
// Worker
// -------------------------------------------------------------------------
private const val KEY_SUB_ID = "subId"
class UpdateTask(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
private val notificationManager = NotificationManagerCompat.from(applicationContext)
private val notification =
NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
.setWhen(0)
.setTicker("Update")
.setContentTitle(context.getString(R.string.title_pref_auto_update_subscription))
.setSmallIcon(R.drawable.ic_stat_name)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
/**
* Performs the subscription update work.
* @return The result of the work.
*/
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
LogUtil.i(AppConfig.TAG, "subscription automatic update starting")
val subId = inputData.getString(KEY_SUB_ID)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update starting: $subId")
val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
for (sub in subs) {
val subItem = sub.subscription
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
val channel =
NotificationChannel(
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
NotificationManager.IMPORTANCE_MIN
)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(3, notification.build())
LogUtil.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
AngConfigManager.updateConfigViaSub(sub)
notification.setContentText("Updating ${subItem.remarks}")
if (subId.isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
return Result.success()
}
notificationManager.cancel(3)
val subItem = MmkvManager.decodeSubscription(subId)
if (subItem == null) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
return Result.success()
}
if (!subItem.autoUpdate) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: auto-update disabled for $subId, skip")
return Result.success()
}
val sub = SubscriptionCache(subId, subItem)
// Notify about update start
NotificationHelper.notify(
NotificationChannelType.SUBSCRIPTION_UPDATE,
applicationContext,
applicationContext.getString(R.string.title_pref_auto_update_subscription),
"Updating ${sub.subscription.remarks}"
)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
AngConfigManager.updateConfigViaSub(sub)
// Clear notification
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
return Result.success()
}
}
@@ -1,6 +1,5 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.Build
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
@@ -12,8 +11,6 @@ import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
object UpdateCheckerManager {
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,9 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.v2ray.ang.AppConfig
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.util.LogUtil
class BootReceiver : BroadcastReceiver() {
@@ -36,6 +37,7 @@ class BootReceiver : BroadcastReceiver() {
}
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
V2RayServiceManager.startVService(context)
CoreServiceManager.startVService(context)
SubscriptionUpdater.sync(context)
}
}
@@ -5,7 +5,7 @@ import android.content.Context
import android.content.Intent
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.util.LogUtil
class TaskerReceiver : BroadcastReceiver() {
@@ -28,12 +28,12 @@ class TaskerReceiver : BroadcastReceiver() {
return
} else if (switch) {
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
V2RayServiceManager.startVServiceFromToggle(context)
CoreServiceManager.startVServiceFromToggle(context)
} else {
V2RayServiceManager.startVService(context, guid)
CoreServiceManager.startVService(context, guid)
}
} else {
V2RayServiceManager.stopVService(context)
CoreServiceManager.stopVService(context)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
@@ -9,7 +9,7 @@ import android.content.Intent
import android.widget.RemoteViews
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
class WidgetProvider : AppWidgetProvider() {
/**
@@ -22,7 +22,7 @@ class WidgetProvider : AppWidgetProvider() {
*/
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.isRunning())
updateWidgetBackground(context, appWidgetManager, appWidgetIds, CoreServiceManager.isRunning())
}
/**
@@ -67,10 +67,10 @@ class WidgetProvider : AppWidgetProvider() {
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(context)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(context)
} else {
V2RayServiceManager.startVServiceFromToggle(context)
CoreServiceManager.startVServiceFromToggle(context)
}
} else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
AppWidgetManager.getInstance(context)?.let { manager ->
@@ -6,20 +6,20 @@ import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import java.lang.ref.SoftReference
class V2RayProxyOnlyService : Service(), ServiceControl {
class CoreProxyOnlyService : Service(), ServiceControl {
/**
* Initializes the service.
*/
override fun onCreate() {
super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
V2RayServiceManager.serviceControl = SoftReference(this)
CoreServiceManager.serviceControl = SoftReference(this)
}
/**
@@ -31,7 +31,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
V2RayServiceManager.startCoreLoop(null)
CoreServiceManager.startCoreLoop(null)
return START_STICKY
}
@@ -40,7 +40,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onDestroy() {
super.onDestroy()
V2RayServiceManager.stopCoreLoop()
CoreServiceManager.stopCoreLoop()
}
/**
@@ -0,0 +1,144 @@
package com.v2ray.ang.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
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.TestServiceMessage
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.NotificationHelper
import java.util.Collections
class CoreTestService : Service() {
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
/**
* Initializes the V2Ray environment.
*/
override fun onCreate() {
super.onCreate()
CoreNativeManager.initCoreEnv(this)
}
/**
* Binds the service.
* @param intent The intent.
* @return The binder.
*/
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* Cleans up resources when the service is destroyed.
*/
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()
NotificationHelper.stopForeground(this)
super.onDestroy()
}
/**
* Handles the start command for the service.
* @param intent The intent.
* @param flags The flags.
* @param startId The start ID.
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val message = intent?.serializable<TestServiceMessage>("content")
if (message == null) {
stopSelf(startId)
return START_NOT_STICKY
}
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(message, startId)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> handleMeasureCancel()
else -> {
NotificationHelper.stopForeground(this); stopSelf(startId)
}
}
return START_NOT_STICKY
}
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
NotificationHelper.startForeground(
this,
NotificationChannelType.CORE_TEST,
getString(R.string.app_name),
getString(R.string.title_real_ping_all_server)
)
val guidsList = when {
message.serverGuids.isNotEmpty() -> message.serverGuids
message.subscriptionId.isNotEmpty() -> MmkvManager.decodeServerList(message.subscriptionId)
else -> MmkvManager.decodeAllServerList()
}
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(
context = this,
guids = guidsList,
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
)
activeWorkers.add(worker)
worker.start()
} else {
NotificationHelper.stopForeground(this)
stopSelf(startId)
}
}
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
channelType = NotificationChannelType.CORE_TEST,
context = this,
content = getString(R.string.connection_runing_task_left, event.text)
)
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
}
is RealPingEvent.Finish -> {
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
onWorkerDone()
if (activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this)
stopSelf()
}
}
}
}
private fun handleMeasureCancel() {
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
NotificationHelper.stopForeground(this)
stopSelf()
}
}
@@ -20,17 +20,17 @@ import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
@SuppressLint("VpnServicePolicy")
class V2RayVpnService : VpnService(), ServiceControl {
class CoreVpnService : VpnService(), ServiceControl {
private lateinit var mInterface: ParcelFileDescriptor
private var isRunning = false
private var tun2SocksService: Tun2SocksControl? = null
@@ -77,7 +77,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this)
CoreServiceManager.serviceControl = SoftReference(this)
}
override fun onRevoke() {
@@ -113,6 +113,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
NotificationManager.showNotification(null)
setupVpnService()
startService()
return START_STICKY
@@ -128,7 +129,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
return
}
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
if (!CoreServiceManager.startCoreLoop(mInterface)) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
stopAllService()
return
@@ -253,7 +254,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
}
builder.setSession(V2RayServiceManager.getRunningServerName())
//builder.setSession(V2RayServiceManager.getRunningServerName())
}
/**
@@ -361,7 +362,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
tun2SocksService?.stopTun2Socks()
tun2SocksService = null
V2RayServiceManager.stopCoreLoop()
CoreServiceManager.stopCoreLoop()
if (isForced) {
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
@@ -0,0 +1,730 @@
package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.Buffer
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.json.JSONArray
import org.json.JSONObject
import java.io.EOFException
import java.net.SocketException
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
// This class is responsible for forwarding xray's HTTP/WS requests through okhttp,
// so that its TLS and traffic characteristics are okhttp instead of golang/utls.
// Only WS and xhttp package-up are supported for now.
// ws:
// DialerNativeService connects to the control WebSocket provided by the xray core.
// Then xray sends a task message with method "WS" and the server URL to the control WebSocket.
// DialerNativeService opens a WebSocket connection to the server URL, and forwards messages between the control WebSocket and the target WebSocket.
// xhttp(package-up):
// A task message with streaming down (method == "GET" and streamResponse == true), let's call it Task A.
// A task message with unary down (streamResponse == false), let's call it Task B.
// 1. DialerNativeService connects to the control WebSocket provided by the xray core.
// 2. Xray sends Task A, DialerNativeService sends "ok" and connects to the target URL, then sends the "GET" request to the server; let's call this "Connection A".
// 3. Xray sends a Task B, called B_1. DialerNativeService sends "ok" and sends the data body to the target URL. Whatever the response is, DialerNativeService sends "ok" or "fail" back to the control WebSocket and closes the connection for Task B_1.
// 4. The server returns the response for Task B_1 through Connection A, and DialerNativeService forwards the response body to the control WebSocket.
// 5. Xray sends another Task B, called B_2.
// ...
// Finally, the xray client core sends all data through the B_1, B_2, ... tasks.
// The server closes Connection A, and DialerNativeService closes Task A.
// The above is a complete cycle.
class DialerNativeService : IDialerService {
companion object {
private const val DEBUG_LOG = false
private val NEXT_SOCKET_ID = AtomicLong(0L)
private const val CONTROL_SOCKET_IDLE = 0
private const val CONTROL_SOCKET_OPENING = 1
private const val CONTROL_LOOP_DELAY_MS = 1000L
private const val UNARY_BODY_WAIT_TIMEOUT_MS = 15_000L
private val TOKEN_REGEX = Regex("""/websocket\?token=([^"'\s]+)""")
private val METHODS_WITHOUT_BODY = setOf("GET", "HEAD")
}
@Volatile
private var serviceJob = SupervisorJob()
@Volatile
private var scope = CoroutineScope(serviceJob + Dispatchers.IO)
private val running = AtomicBoolean(false)
private val controlSocketState = AtomicInteger(CONTROL_SOCKET_IDLE)
private val controlSockets = ConcurrentHashMap.newKeySet<WebSocket>()
@Volatile
private var controlUrl: String? = null
private var loopJob: Job? = null
private var client: OkHttpClient? = null
@Suppress("UNUSED_PARAMETER")
override fun start(context: Context, dialerAddr: String) {
stop()
serviceJob = SupervisorJob()
scope = CoroutineScope(serviceJob + Dispatchers.IO)
if (dialerAddr.isEmpty()) return
val nativeClient = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.pingInterval(25, TimeUnit.SECONDS)
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // Disable read timeout for long-running streams
.build()
client = nativeClient
running.set(true)
loopJob = scope.launch {
val resolvedControlUrl = resolveControlWsUrl(dialerAddr, nativeClient)
if (resolvedControlUrl == null) {
debug(
"BrowserDialer: failed to resolve control url from dialer endpoint: $dialerAddr"
)
running.set(false)
return@launch
}
controlUrl = resolvedControlUrl
debug("BrowserDialer: started dialerAddr=$dialerAddr controlUrl=$resolvedControlUrl idleGate=${controlSocketState.get()}")
maintainControlSocketPool()
while (isActive && running.get()) {
maintainControlSocketPool()
delay(CONTROL_LOOP_DELAY_MS)
}
}
}
override fun stop() {
running.set(false)
loopJob?.cancel()
loopJob = null
serviceJob.cancel()
debug("BrowserDialer: stopping ${poolState()} controlUrl=$controlUrl")
controlSockets.toTypedArray().forEach { socket ->
runCatching { socket.close(1000, "stopped") }
}
controlSockets.clear()
controlSocketState.set(CONTROL_SOCKET_IDLE)
controlUrl = null
serviceJob = SupervisorJob()
scope = CoroutineScope(serviceJob + Dispatchers.IO)
val oldClient = client
client = null
oldClient?.dispatcher?.cancelAll()
oldClient?.connectionPool?.evictAll()
}
private fun maintainControlSocketPool() {
debug("BrowserDialer: maintaining single idle control socket ${poolState()}")
openControlSocket()
}
private fun openControlSocket(): Boolean {
val localClient = client ?: return false
if (!running.get()) return false
val url = controlUrl ?: return false
if (!controlSocketState.compareAndSet(
CONTROL_SOCKET_IDLE,
CONTROL_SOCKET_OPENING
)
) return false
val request = Request.Builder().url(url).build()
return runCatching {
val socket = localClient.newWebSocket(request, ControlSocketListener(url))
controlSockets.add(socket)
debug("BrowserDialer: opening control socket url=$url ${poolState()}")
true
}.getOrElse {
controlSocketState.set(CONTROL_SOCKET_IDLE)
debug("BrowserDialer: failed to open control socket", it)
false
}
}
private fun poolState(): String {
return "idleGate=${controlSocketState.get()} liveSockets=${controlSockets.size}"
}
private fun debug(message: String, throwable: Throwable? = null) {
@Suppress("KotlinConstantConditions")
if (!DEBUG_LOG) return
if (throwable == null) {
LogUtil.d(AppConfig.TAG, message)
} else {
LogUtil.d(AppConfig.TAG, message, throwable)
}
}
private fun resolveControlWsUrl(rawAddr: String, probeClient: OkHttpClient): String? {
val uri = parseDialerUri(rawAddr) ?: return null
val probeUrl = buildDialerProbeUrl(rawAddr) ?: return null
val request = Request.Builder().url(probeUrl).get().build()
val token = runCatching {
probeClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
extractControlToken(response.body.string())
}
}.getOrNull() ?: return null
val host = uri.host ?: return null
return URI(
"ws",
uri.userInfo,
host,
uri.port,
"/websocket",
"token=$token",
null
).toString()
}
private fun buildDialerProbeUrl(rawAddr: String): String? {
val normalized = rawAddr.trim()
if (normalized.isEmpty()) return null
val uri = parseDialerUri(normalized) ?: return null
val host = uri.host ?: return null
val probeScheme = when (uri.scheme?.lowercase()) {
"https", "wss" -> "https"
else -> "http"
}
return URI(probeScheme, uri.userInfo, host, uri.port, "/", null, null).toString()
}
private fun parseDialerUri(rawAddr: String): URI? {
val normalized = rawAddr.trim()
if (normalized.isEmpty()) return null
return runCatching {
if (normalized.contains("://")) URI(normalized) else URI("http://$normalized")
}.getOrNull()
}
private fun extractControlToken(html: String): String? {
val match = TOKEN_REGEX.find(html) ?: return null
return match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() }
}
private inner class ControlSocketListener(
private val controlUrl: String
) : WebSocketListener() {
private val socketId = if (DEBUG_LOG) NEXT_SOCKET_ID.incrementAndGet() else 0L
private val taskAccepted = AtomicBoolean(false)
private val closed = AtomicBoolean(false)
private val taskStartedAtMs = AtomicLong(0L)
@Volatile
private var taskKind = "none"
private var upstreamSocket: WebSocket? = null
private var upstreamCall: Call? = null
private var timeoutJob: Job? = null
private var binaryHandler: ((ByteArray) -> Unit)? = null
private var textHandler: ((String) -> Unit)? = null
override fun onOpen(webSocket: WebSocket, response: Response) {
debug("BrowserDialer: control socket opened socketId=$socketId url=$controlUrl ${poolState()}")
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (taskAccepted.compareAndSet(false, true)) {
controlSocketState.set(CONTROL_SOCKET_IDLE)
debug(
"BrowserDialer: control socket accepted task socketId=$socketId url=$controlUrl textSize=${text.length} ${poolState()}"
)
tryOpenNextControlSocket()
handleTask(webSocket, BrowserDialerTask.parse(text))
return
}
textHandler?.invoke(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
if (!taskAccepted.get()) {
failAndClose(webSocket, 1002, "task must be text json")
return
}
binaryHandler?.invoke(bytes.toByteArray())
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
cleanup(webSocket)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val status = response?.code?.toString() ?: "no-http-response"
val stateText = "${poolState()} accepted=${taskAccepted.get()} closed=${closed.get()}"
if (isExpectedControlFailure(t, status)) {
debug(
"BrowserDialer: control socket closed socketId=$socketId url=$controlUrl status=$status cause=${t.javaClass.simpleName} $stateText"
)
debug(
"BrowserDialer: control socket failure detail socketId=$socketId url=$controlUrl status=$status $stateText",
t
)
} else {
debug(
"BrowserDialer: control socket failure socketId=$socketId url=$controlUrl status=$status $stateText",
t
)
}
cleanup(webSocket)
}
private fun isExpectedControlFailure(t: Throwable, status: String): Boolean {
if (!running.get() || closed.get()) return true
if (status != "no-http-response") return false
if (t is EOFException) return true
if (t is SocketException) {
val message = t.message.orEmpty().lowercase()
if ("socket closed" in message || "software caused connection abort" in message) {
return true
}
}
val message = t.message.orEmpty().lowercase()
return "canceled" in message || "cancelled" in message
}
private fun tryOpenNextControlSocket() {
scope.launch {
if (running.get()) maintainControlSocketPool()
}
}
private fun cleanup(webSocket: WebSocket) {
if (!closed.compareAndSet(false, true)) return
val removed = controlSockets.remove(webSocket)
if (!taskAccepted.get()) {
controlSocketState.set(CONTROL_SOCKET_IDLE)
tryOpenNextControlSocket()
}
val started = taskStartedAtMs.get()
val duration =
if (started > 0L) (System.currentTimeMillis() - started).coerceAtLeast(0L) else -1L
debug(
"BrowserDialer: cleanup socketId=$socketId url=$controlUrl task=$taskKind taskAccepted=${taskAccepted.get()} removed=$removed durationMs=$duration ${poolState()}"
)
binaryHandler = null
textHandler = null
timeoutJob?.cancel()
timeoutJob = null
upstreamCall?.cancel()
upstreamCall = null
upstreamSocket?.close(1000, "control closed")
upstreamSocket = null
taskKind = "closed"
taskStartedAtMs.set(0L)
}
private fun handleTask(webSocket: WebSocket, task: BrowserDialerTask?) {
if (task == null) {
failAndClose(webSocket, 1007, "invalid task")
return
}
taskStartedAtMs.set(System.currentTimeMillis())
taskKind = when {
task.method == "WS" -> "ws"
task.method == "GET" && task.streamResponse -> "streaming_get"
!task.streamResponse -> "unary_${task.method.lowercase()}"
else -> "unsupported"
}
when {
task.method == "WS" -> handleWsTask(webSocket, task)
task.method == "GET" && task.streamResponse -> handleStreamingGetTask(
webSocket,
task
)
!task.streamResponse -> handleUnaryTask(webSocket, task)
else -> {
failAndClose(webSocket, 1003, "unsupported task")
}
}
}
private fun handleWsTask(controlSocket: WebSocket, task: BrowserDialerTask) {
val localClient = client ?: run {
failAndClose(controlSocket, 1011, "client unavailable")
return
}
debug("BrowserDialer: handling WS task socketId=$socketId url=${task.url} protocols=${task.extra.protocols.size}")
val requestBuilder = Request.Builder().url(task.url)
if (task.extra.protocols.isNotEmpty()) {
requestBuilder.header(
"Sec-WebSocket-Protocol",
task.extra.protocols.joinToString(",")
)
}
val opened = AtomicBoolean(false)
upstreamSocket =
localClient.newWebSocket(requestBuilder.build(), object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
opened.set(true)
try {
controlSocket.send("ok")
} catch (e: Exception) {
debug(
"BrowserDialer: failed to send ok for WS task",
e
)
webSocket.close(1000, "control failed")
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
controlSocket.send(text)
} catch (e: Exception) {
debug(
"BrowserDialer: control socket closed during WS message transfer socketId=$socketId",
e
)
webSocket.close(1000, "control closed")
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
try {
controlSocket.send(bytes)
} catch (e: Exception) {
debug(
"BrowserDialer: control socket closed during WS binary transfer socketId=$socketId",
e
)
webSocket.close(1000, "control closed")
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
try {
controlSocket.close(1000, "upstream closed")
} catch (_: Exception) {
debug("BrowserDialer: control socket already closed")
}
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?
) {
if (!opened.get()) {
try {
controlSocket.send("fail")
} catch (e: Exception) {
debug(
"BrowserDialer: control socket send failed socketId=$socketId",
e
)
}
}
try {
controlSocket.close(1011, "upstream failure")
} catch (_: Exception) {
debug(
"BrowserDialer: control socket already closed socketId=$socketId"
)
}
}
})
textHandler = { message ->
try {
upstreamSocket?.send(message)
} catch (e: Exception) {
debug(
"BrowserDialer: upstream socket send failed socketId=$socketId",
e
)
}
}
binaryHandler = { data ->
try {
upstreamSocket?.send(data.toByteString())
} catch (e: Exception) {
debug(
"BrowserDialer: upstream socket binary send failed socketId=$socketId",
e
)
}
}
}
private fun handleStreamingGetTask(controlSocket: WebSocket, task: BrowserDialerTask) {
val localClient = client ?: run {
failAndClose(controlSocket, 1011, "client unavailable")
return
}
debug("BrowserDialer: handling streaming GET task socketId=$socketId url=${task.url}")
val request = buildRequest(task, null)
try {
controlSocket.send("ok")
} catch (e: Exception) {
debug(
"BrowserDialer: failed to send ok for streaming GET socketId=$socketId",
e
)
return
}
scope.launch {
val call = localClient.newCall(request)
upstreamCall = call
try {
call.execute().use { response ->
val source = response.body.source()
val buffer = Buffer()
while (running.get() && !closed.get()) {
try {
val read = source.read(buffer, DEFAULT_BUFFER_SIZE.toLong())
if (read < 0) break
// Send data, catch exception if WebSocket is closed
try {
controlSocket.send(buffer.readByteString())
} catch (e: Exception) {
debug(
"BrowserDialer: WebSocket send failed during streaming, stopping stream socketId=$socketId",
e
)
break
}
} catch (_: Exception) {
// Error reading from source, stop streaming
break
}
}
}
} catch (e: Exception) {
debug(
"BrowserDialer: streaming GET failed socketId=$socketId",
e
)
try {
controlSocket.send("fail")
} catch (_: Exception) {
// WebSocket may already be closed
}
} finally {
upstreamCall = null
try {
controlSocket.close(1000, "streaming done")
} catch (_: Exception) {
debug(
"BrowserDialer: WebSocket already closed socketId=$socketId"
)
}
}
}
}
private fun handleUnaryTask(controlSocket: WebSocket, task: BrowserDialerTask) {
val localClient = client ?: run {
failAndClose(controlSocket, 1011, "client unavailable")
return
}
debug("BrowserDialer: handling unary task socketId=$socketId method=${task.method} url=${task.url}")
try {
controlSocket.send("ok")
} catch (e: Exception) {
debug(
"BrowserDialer: failed to send ok for unary task socketId=$socketId",
e
)
return
}
val done = AtomicBoolean(false)
timeoutJob = scope.launch {
delay(UNARY_BODY_WAIT_TIMEOUT_MS)
if (done.compareAndSet(false, true)) {
binaryHandler = null
textHandler = null
debug("BrowserDialer: unary task timed out waiting for payload socketId=$socketId method=${task.method} url=${task.url}")
failAndClose(controlSocket, 1000, "unary payload timeout")
}
}
val executeRequest: (ByteArray?) -> Unit = { payload ->
if (done.compareAndSet(false, true)) {
timeoutJob?.cancel()
timeoutJob = null
binaryHandler = null
textHandler = null
scope.launch {
val request = buildRequest(task, payload)
val call = localClient.newCall(request)
upstreamCall = call
try {
call.execute().use { response ->
try {
controlSocket.send(if (response.isSuccessful) "ok" else "fail")
} catch (e: Exception) {
debug(
"BrowserDialer: WebSocket send failed for unary response socketId=$socketId",
e
)
}
}
} catch (e: Exception) {
debug(
"BrowserDialer: unary request failed socketId=$socketId",
e
)
try {
controlSocket.send("fail")
} catch (_: Exception) {
// WebSocket may already be closed
}
} finally {
upstreamCall = null
try {
controlSocket.close(1000, "request done")
} catch (_: Exception) {
debug(
"BrowserDialer: WebSocket already closed socketId=$socketId"
)
}
}
}
}
}
binaryHandler = { body -> executeRequest(body.takeIf { it.isNotEmpty() }) }
textHandler = { body -> executeRequest(body.toByteArray().takeIf { it.isNotEmpty() }) }
}
private fun buildRequest(task: BrowserDialerTask, payload: ByteArray?): Request {
val requestBuilder = Request.Builder().url(task.url)
// task.extra.headers.forEach { (key, value) -> requestBuilder.header(key, value) }
// Just set no cache headers
requestBuilder.header("Cache-Control", "no-cache, no-store, must-revalidate")
task.extra.referrer?.takeIf { it.isNotBlank() }
?.let { requestBuilder.header("Referer", it) }
val method = task.method.uppercase()
val methodAllowsBody = method !in METHODS_WITHOUT_BODY
val body = when {
methodAllowsBody && payload != null && payload.isNotEmpty() -> payload.toRequestBody(null)
methodAllowsBody -> ByteArray(0).toRequestBody(null)
else -> null
}
requestBuilder.method(method, body)
return requestBuilder.build()
}
private fun failAndClose(socket: WebSocket, code: Int, reason: String) {
try {
socket.send("fail")
} catch (e: Exception) {
debug("BrowserDialer: failed to send fail message", e)
}
try {
socket.close(code, reason)
} catch (e: Exception) {
debug("BrowserDialer: failed to close socket", e)
}
}
}
private data class BrowserDialerTask(
val method: String,
val url: String,
val streamResponse: Boolean,
val extra: Extra
) {
data class Extra(
// val headers: Map<String, String> = emptyMap(),
// val cookies: Map<String, String> = emptyMap(),
val protocols: List<String> = emptyList(),
val referrer: String? = null
)
companion object {
fun parse(payload: String): BrowserDialerTask? {
return runCatching {
val root = JSONObject(payload)
val method = root.optString("method")
val url = root.optString("url")
if (method.isBlank() || url.isBlank()) return null
val streamResponse = root.optBoolean("streamResponse", false)
val extraObject = root.optJSONObject("extra")
// val headers = extraObject.optStringMap("headers")
// val cookies = extraObject.optStringMap("cookies")
val referrer = extraObject?.optString("referrer")?.takeIf { it.isNotBlank() }
val protocols = extraObject.optProtocols()
BrowserDialerTask(
method = method,
url = url,
streamResponse = streamResponse,
extra = Extra(
// headers = headers,
// cookies = cookies,
protocols = protocols,
referrer = referrer
)
)
}.getOrNull()
}
// private fun JSONObject?.optStringMap(name: String): Map<String, String> {
// val child = this?.optJSONObject(name) ?: return emptyMap()
// val map = LinkedHashMap<String, String>()
// val iter = child.keys()
// while (iter.hasNext()) {
// val key = iter.next()
// map[key] = child.optString(key)
// }
// return map
// }
private fun JSONObject?.optProtocols(): List<String> {
val raw = this?.opt("protocol") ?: return emptyList()
return when (raw) {
is String -> raw.takeIf { it.isNotBlank() }?.let { listOf(it) } ?: emptyList()
is JSONArray -> buildList {
for (i in 0 until raw.length()) {
val item = raw.optString(i)
if (item.isNotBlank()) {
add(item)
}
}
}
else -> emptyList()
}
}
}
}
}
@@ -0,0 +1,73 @@
package com.v2ray.ang.service
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
class DialerWebviewService : IDialerService {
private var webView: WebView? = null
private val handler = Handler(Looper.getMainLooper())
private val keepAliveInterval = 30_000L // 30 seconds
private val keepAliveRunnable = object : Runnable {
override fun run() {
webView?.let {
it.resumeTimers()
it.onResume()
}
handler.postDelayed(this, keepAliveInterval)
}
}
/**
* Starts the WebView.
* @param context Service context
*/
@SuppressLint("SetJavaScriptEnabled")
override fun start(context: Context, dialerAddr: String) {
if (webView != null) stop()
if (dialerAddr.isEmpty()) return
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()
}
}
loadUrl(dialerUrl)
}
handler.post(keepAliveRunnable)
}
override fun stop() {
handler.removeCallbacks(keepAliveRunnable)
webView?.apply {
stopLoading()
pauseTimers()
// Important to call onPause to stop internal Chromium threads properly
onPause()
destroy()
}
webView = null
}
}
@@ -0,0 +1,8 @@
package com.v2ray.ang.service
import android.content.Context
interface IDialerService {
fun start(context: Context, dialerAddr: String)
fun stop()
}
@@ -10,7 +10,7 @@ import android.service.quicksettings.TileService
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
@@ -29,7 +29,7 @@ class QSTileService : TileService() {
qsTile?.label = getString(R.string.app_name)
} else if (state == Tile.STATE_ACTIVE) {
qsTile?.state = Tile.STATE_ACTIVE
qsTile?.label = V2RayServiceManager.getRunningServerName()
qsTile?.label = CoreServiceManager.getRunningServerName()
}
qsTile?.updateTile()
@@ -42,7 +42,7 @@ class QSTileService : TileService() {
override fun onStartListening() {
super.onStartListening()
if (V2RayServiceManager.isRunning()) {
if (CoreServiceManager.isRunning()) {
setState(Tile.STATE_ACTIVE)
} else {
setState(Tile.STATE_INACTIVE)
@@ -75,11 +75,11 @@ class QSTileService : TileService() {
super.onClick()
when (qsTile.state) {
Tile.STATE_INACTIVE -> {
V2RayServiceManager.startVServiceFromToggle(this)
CoreServiceManager.startVServiceFromToggle(this)
}
Tile.STATE_ACTIVE -> {
V2RayServiceManager.stopVService(this)
CoreServiceManager.stopVService(this)
}
}
}
@@ -1,11 +1,10 @@
package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
@@ -23,11 +22,11 @@ import java.util.concurrent.atomic.AtomicInteger
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
private val onFinish: (status: String) -> Unit = {}
private val onEvent: (RealPingEvent) -> Unit = {}
) {
private val job = SupervisorJob()
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
private val concurrency = SettingsManager.getRealPingConcurrency()
private val dispatcher = Executors.newFixedThreadPool(concurrency).asCoroutineDispatcher()
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
private val runningCount = AtomicInteger(0)
@@ -40,11 +39,13 @@ class RealPingWorkerService(
runningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
onEvent(RealPingEvent.Result(guid, result))
} catch (_: Throwable) {
// ignore
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
onEvent(RealPingEvent.Progress("$left / $count"))
}
}
}
@@ -52,9 +53,9 @@ class RealPingWorkerService(
scope.launch {
try {
joinAll(*jobs.toTypedArray())
onFinish("0")
onEvent(RealPingEvent.Finish("0"))
} catch (_: CancellationException) {
onFinish("-1")
onEvent(RealPingEvent.Finish("-1"))
} finally {
close()
}
@@ -75,11 +76,10 @@ class RealPingWorkerService(
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
val configResult = CoreConfigManager.getV2rayConfig4Speedtest(context, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
@@ -1,89 +0,0 @@
package com.v2ray.ang.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.util.MessageUtil
import java.util.Collections
class V2RayTestService : Service() {
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
/**
* Initializes the V2Ray environment.
*/
override fun onCreate() {
super.onCreate()
V2RayNativeManager.initCoreEnv(this)
}
/**
* Binds the service.
* @param intent The intent.
* @return The binder.
*/
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* Cleans up resources when the service is destroyed.
*/
override fun onDestroy() {
super.onDestroy()
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
}
/**
* Handles the start command for the service.
* @param intent The intent.
* @param flags The flags.
* @param startId The start ID.
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val message = intent?.serializable<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
when (message.key) {
MSG_MEASURE_CONFIG -> {
val guidsList = if (message.serverGuids.isNotEmpty()) {
message.serverGuids
} else if (message.subscriptionId.isNotEmpty()) {
MmkvManager.decodeServerList(message.subscriptionId)
} else {
MmkvManager.decodeAllServerList()
}
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(this, guidsList) { status ->
// notify UI and remove the worker from active list when finished
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
activeWorkers.remove(worker)
}
activeWorkers.add(worker)
worker.start()
}
}
MSG_MEASURE_CONFIG_CANCEL -> {
// cancel all running batch workers independently
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
}
}
return super.onStartCommand(intent, flags, startId)
}
}
@@ -4,8 +4,8 @@ import android.os.Bundle
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.databinding.ActivityAboutBinding
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.util.Utils
class AboutActivity : BaseActivity() {
@@ -42,7 +42,7 @@ class AboutActivity : BaseActivity() {
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
}
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
BuildConfig.APPLICATION_ID.also {
@@ -14,7 +14,6 @@ import com.v2ray.ang.databinding.ActivityAppPickerBinding
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.PackageUidResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -6,6 +6,7 @@ import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.extension.toast
@@ -13,7 +14,6 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.UpdateCheckerManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
@@ -36,7 +36,7 @@ class CheckUpdateActivity : BaseActivity() {
}
binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
@@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
@@ -23,6 +24,7 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.MainViewModel
@@ -44,6 +46,11 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
private val share_method_more: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method_more)
}
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
companion object {
private const val ARG_SUB_ID = "subscriptionId"
@@ -168,23 +175,20 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val intent = Intent().putExtra("guid", guid)
val activityClass = when (profile.configType) {
EConfigType.CUSTOM -> ServerCustomConfigActivity::class.java
EConfigType.POLICYGROUP -> ServerGroupActivity::class.java
EConfigType.PROXYCHAIN -> ServerProxyChainActivity::class.java
else -> ServerActivity::class.java
}
val intent = Intent(ownerActivity, activityClass)
.putExtra("guid", guid)
.putExtra("isRunning", mainViewModel.isRunning.value)
.putExtra("createConfigType", profile.configType.value)
.putExtra("subscriptionId", subId)
when (profile.configType) {
EConfigType.CUSTOM -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
}
EConfigType.POLICYGROUP -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerGroupActivity::class.java))
}
else -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerActivity::class.java))
}
}
launcher.launch(intent)
}
/**
@@ -265,7 +269,9 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
val isCustom = profile.configType == EConfigType.CUSTOM
|| profile.configType == EConfigType.POLICYGROUP
|| profile.configType == EConfigType.PROXYCHAIN
val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
@@ -22,6 +22,7 @@ import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayoutMediator
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.databinding.ActivityMainBinding
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.PermissionType
@@ -31,7 +32,7 @@ import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel
@@ -98,6 +99,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
setupGroupTab()
setupViewModel()
SubscriptionUpdater.sync()
mainViewModel.reloadServerList()
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
@@ -135,7 +137,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
applyRunningState(isLoading = true, isRunning = false)
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
CoreServiceManager.stopVService(this)
} else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this)
if (intent == null) {
@@ -162,12 +164,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
toast(R.string.title_file_chooser)
return
}
V2RayServiceManager.startVService(this)
CoreServiceManager.startVService(this)
}
fun restartV2Ray() {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
CoreServiceManager.stopVService(this)
}
lifecycleScope.launch {
delay(500)
@@ -252,6 +254,11 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
true
}
R.id.import_manually_proxy_chain -> {
importManually(EConfigType.PROXYCHAIN.value)
true
}
R.id.import_manually_vmess -> {
importManually(EConfigType.VMESS.value)
true
@@ -354,6 +361,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
.putExtra("subscriptionId", mainViewModel.subscriptionId)
.setClass(this, ServerGroupActivity::class.java)
)
} else if (createConfigType == EConfigType.PROXYCHAIN.value) {
startActivity(
Intent()
.putExtra("subscriptionId", mainViewModel.subscriptionId)
.setClass(this, ServerProxyChainActivity::class.java)
)
} else {
startActivity(
Intent()
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
class ScStartActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,8 +11,8 @@ class ScStartActivity : BaseActivity() {
setContentView(R.layout.activity_none)
if (!V2RayServiceManager.isRunning()) {
V2RayServiceManager.startVServiceFromToggle(this)
if (!CoreServiceManager.isRunning()) {
CoreServiceManager.startVServiceFromToggle(this)
}
finish()
}
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
class ScStopActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,8 +11,8 @@ class ScStopActivity : BaseActivity() {
setContentView(R.layout.activity_none)
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(this)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(this)
}
finish()
}
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
class ScSwitchActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,10 +11,10 @@ class ScSwitchActivity : BaseActivity() {
setContentView(R.layout.activity_none)
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(this)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(this)
} else {
V2RayServiceManager.startVServiceFromToggle(this)
CoreServiceManager.startVServiceFromToggle(this)
}
finish()
}
@@ -29,6 +29,7 @@ import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
@@ -84,8 +85,8 @@ class ServerActivity : BaseActivity() {
private val xhttpMode: Array<out String> by lazy {
resources.getStringArray(R.array.xhttp_mode)
}
private val echForceQuerys: Array<out String> by lazy {
resources.getStringArray(R.array.ech_force_query_value)
private val browserDialerModes: Array<out String> by lazy {
resources.getStringArray(R.array.browser_dialer_mode)
}
@@ -132,15 +133,18 @@ class ServerActivity : BaseActivity() {
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
private val et_kcp_mtu: EditText? by lazy { findViewById(R.id.et_kcp_mtu) }
private val et_kcp_tti: EditText? by lazy { findViewById(R.id.et_kcp_tti) }
private val layout_kcp: LinearLayout? by lazy { findViewById(R.id.layout_kcp) }
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
private val et_fm: EditText? by lazy { findViewById(R.id.et_fm) }
private val layout_extra: LinearLayout? by lazy { findViewById(R.id.layout_extra) }
private val et_ech_config_list: EditText? by lazy { findViewById(R.id.et_ech_config_list) }
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
private val sp_ech_force_query: Spinner? by lazy { findViewById(R.id.sp_ech_force_query) }
private val container_ech_force_query: LinearLayout? by lazy { findViewById(R.id.lay_ech_force_query) }
private val et_pinned_ca256: EditText? by lazy { findViewById(R.id.et_pinned_ca256) }
private val container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) }
private val layout_browser_dialer: LinearLayout? by lazy { findViewById(R.id.layout_browser_dialer) }
private val sp_browser_dialer_mode: Spinner? by lazy { findViewById(R.id.sp_browser_dialer_mode) }
override fun onCreate(savedInstanceState: Bundle?) {
@@ -150,14 +154,12 @@ class ServerActivity : BaseActivity() {
val layoutId = when (config?.configType ?: createConfigType) {
EConfigType.VMESS -> R.layout.activity_server_vmess
EConfigType.CUSTOM -> null
EConfigType.SHADOWSOCKS -> R.layout.activity_server_shadowsocks
EConfigType.SOCKS, EConfigType.HTTP -> R.layout.activity_server_socks
EConfigType.VLESS -> R.layout.activity_server_vless
EConfigType.TROJAN -> R.layout.activity_server_trojan
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
EConfigType.POLICYGROUP -> null
else -> null
} ?: return
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
@@ -245,11 +247,26 @@ class ServerActivity : BaseActivity() {
)
et_fm?.text = Utils.getEditable(config?.finalMask)
layout_kcp?.visibility =
when (networks[position]) {
NetworkType.KCP.type -> View.VISIBLE
else -> View.GONE
}
et_kcp_mtu?.text = Utils.getEditable(config?.kcpMtu?.toString().orEmpty())
et_kcp_tti?.text = Utils.getEditable(config?.kcpTti?.toString().orEmpty())
layout_extra?.visibility =
when (networks[position]) {
NetworkType.XHTTP.type -> View.VISIBLE
else -> View.GONE
}
layout_browser_dialer?.visibility =
when (networks[position]) {
NetworkType.WS.type -> View.VISIBLE
NetworkType.XHTTP.type -> View.VISIBLE
else -> View.GONE
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {
@@ -279,7 +296,6 @@ class ServerActivity : BaseActivity() {
container_spider_x,
container_mldsa65_verify,
container_ech_config_list,
container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.GONE }
}
@@ -292,7 +308,6 @@ class ServerActivity : BaseActivity() {
container_alpn,
container_allow_insecure,
container_ech_config_list,
container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.VISIBLE }
listOf(
@@ -313,7 +328,6 @@ class ServerActivity : BaseActivity() {
container_alpn,
container_allow_insecure,
container_ech_config_list,
container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.GONE }
listOf(
@@ -397,10 +411,6 @@ class ServerActivity : BaseActivity() {
sp_allow_insecure?.setSelection(allowinsecure)
}
et_ech_config_list?.text = Utils.getEditable(config.echConfigList)
config.echForceQuery?.let { it ->
val index = Utils.arrayFind(echForceQuerys, it)
index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) }
}
et_pinned_ca256?.text = Utils.getEditable(config.pinnedCA256)
} else if (config.security == REALITY) {
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
@@ -414,6 +424,12 @@ class ServerActivity : BaseActivity() {
if (network >= 0) {
sp_network?.setSelection(network)
}
val browserDialerMode = Utils.arrayFind(browserDialerModes, config.browserDialerMode.orEmpty())
if (browserDialerMode >= 0) {
sp_browser_dialer_mode?.setSelection(browserDialerMode)
}
return true
}
@@ -442,6 +458,7 @@ class ServerActivity : BaseActivity() {
et_local_address?.text =
Utils.getEditable(WIREGUARD_LOCAL_ADDRESS_V4)
et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU)
sp_browser_dialer_mode?.setSelection(0)
return true
}
@@ -510,6 +527,9 @@ class ServerActivity : BaseActivity() {
}
//LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
MmkvManager.encodeServerConfig(editGuid, config)
if (isRunning) {
SettingsChangeManager.makeRestartService()
}
toastSuccess(R.string.toast_success)
finish()
return true
@@ -569,6 +589,18 @@ class ServerActivity : BaseActivity() {
profileItem.xhttpMode = transportTypes(networks[network])[type]
profileItem.xhttpExtra = et_extra?.text?.toString()?.trim().nullIfBlank()
profileItem.finalMask = et_fm?.text?.toString()?.trim()?.nullIfBlank()
profileItem.kcpMtu = et_kcp_mtu?.text?.toString()?.toIntOrNull()
profileItem.kcpTti = et_kcp_tti?.text?.toString()?.toIntOrNull()
if (networks[network] == NetworkType.WS.type || networks[network] == NetworkType.XHTTP.type) {
val browserDialerMode = browserDialerModes[sp_browser_dialer_mode?.selectedItemPosition ?: 0]
if (browserDialerMode != browserDialerModes[0]) {
profileItem.browserDialerMode = browserDialerMode
} else {
profileItem.browserDialerMode = null
}
} else {
profileItem.browserDialerMode = null
}
}
private fun saveTls(config: ProfileItem) {
@@ -582,7 +614,6 @@ class ServerActivity : BaseActivity() {
val spiderX = et_spider_x?.text?.toString()
val mldsa65Verify = et_mldsa65_verify?.text?.toString()
val echConfigList = et_ech_config_list?.text?.toString()
val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0
val pinnedCA256 = et_pinned_ca256?.text?.toString()
val allowInsecure =
@@ -602,7 +633,6 @@ class ServerActivity : BaseActivity() {
config.spiderX = spiderX
config.mldsa65Verify = mldsa65Verify
config.echConfigList = echConfigList
config.echForceQuery = echForceQuerys[echForceQueryIndex]
config.pinnedCA256 = pinnedCA256
}
@@ -659,17 +689,9 @@ class ServerActivity : BaseActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delButton = menu.findItem(R.id.del_config)
val saveButton = menu.findItem(R.id.save_config)
if (editGuid.isNotEmpty()) {
if (isRunning) {
delButton?.isVisible = false
saveButton?.isVisible = false
}
} else {
delButton?.isVisible = false
}
val delButton = menu.findItem(R.id.del_config)
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
return super.onCreateOptionsMenu(menu)
}
@@ -17,6 +17,7 @@ import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
@@ -94,6 +95,9 @@ class ServerCustomConfigActivity : BaseActivity() {
MmkvManager.encodeServerConfig(editGuid, config)
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
if (isRunning) {
SettingsChangeManager.makeRestartService()
}
toastSuccess(R.string.toast_success)
finish()
return true
@@ -119,17 +123,9 @@ class ServerCustomConfigActivity : BaseActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delButton = menu.findItem(R.id.del_config)
val saveButton = menu.findItem(R.id.save_config)
if (editGuid.isNotEmpty()) {
if (isRunning) {
delButton?.isVisible = false
saveButton?.isVisible = false
}
} else {
delButton?.isVisible = false
}
val delButton = menu.findItem(R.id.del_config)
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
return super.onCreateOptionsMenu(menu)
}
@@ -14,6 +14,7 @@ import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.util.Utils
class ServerGroupActivity : BaseActivity() {
@@ -100,6 +101,9 @@ class ServerGroupActivity : BaseActivity() {
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
MmkvManager.encodeServerConfig(editGuid, config)
if (isRunning) {
SettingsChangeManager.makeRestartService()
}
toastSuccess(R.string.toast_success)
finish()
return true
@@ -143,17 +147,9 @@ class ServerGroupActivity : BaseActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delButton = menu.findItem(R.id.del_config)
val saveButton = menu.findItem(R.id.save_config)
if (editGuid.isNotEmpty()) {
if (isRunning) {
delButton?.isVisible = false
saveButton?.isVisible = false
}
} else {
delButton?.isVisible = false
}
val delButton = menu.findItem(R.id.del_config)
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
return super.onCreateOptionsMenu(menu)
}
@@ -0,0 +1,224 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.Utils
class ServerProxyChainActivity : BaseActivity() {
private val binding by lazy { ActivityServerProxyChainBinding.inflate(layoutInflater) }
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 subscriptionId by lazy {
intent.getStringExtra("subscriptionId")
}
private lateinit var memberAdapter: ServerProxyChainMemberAdapter
private var allRemarks: List<String> = emptyList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.PROXYCHAIN.toString())
loadAvailableRemarks()
setupRecycler()
binding.fabAddProxyChainMember.setOnClickListener {
addMemberRow()
}
val config = MmkvManager.decodeServerConfig(editGuid)
if (config != null) {
bindingServer(config)
} else {
clearServer()
}
}
private fun loadAvailableRemarks() {
allRemarks = MmkvManager.decodeAllServerList()
.asSequence()
.mapNotNull { guid -> MmkvManager.decodeServerConfig(guid) }
.filter { profile ->
profile.configType != EConfigType.CUSTOM
&& profile.configType != EConfigType.POLICYGROUP
&& profile.configType != EConfigType.PROXYCHAIN
}
.map { it.remarks.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
}
private fun setupRecycler() {
memberAdapter = ServerProxyChainMemberAdapter(
members = mutableListOf(),
suggestions = allRemarks,
adapterListener = ActivityAdapterListener()
)
binding.recyclerProxyChainMembers.layoutManager = LinearLayoutManager(this)
binding.recyclerProxyChainMembers.adapter = memberAdapter
ItemTouchHelper(SimpleItemTouchHelperCallback(memberAdapter)).attachToRecyclerView(binding.recyclerProxyChainMembers)
}
private fun bindingServer(config: ProfileItem): Boolean {
binding.etRemarks.text = Utils.getEditable(config.remarks)
val rows = parseChainMembers(config.proxyChainProfiles)
memberAdapter.replaceAll(rows)
if (rows.isEmpty()) {
memberAdapter.addRow()
}
return true
}
private fun clearServer(): Boolean {
binding.etRemarks.text = null
memberAdapter.replaceAll(listOf("", ""))
return true
}
private fun saveServer(): Boolean {
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
toast(R.string.server_lab_remarks)
return false
}
val chainMembers = memberAdapter.getMembers().map { it.trim() }.filter { it.isNotEmpty() }
if (chainMembers.size != memberAdapter.getMembers().size) {
toast(R.string.server_proxy_chain_members_unselected)
return false
}
if (chainMembers.size < 2) {
toast(R.string.server_proxy_chain_members_insufficient)
return false
}
val invalidMembers = chainMembers.filter { member ->
val profile = SettingsManager.getServerViaRemarks(member)
profile == null
|| profile.configType == EConfigType.CUSTOM
|| profile.configType == EConfigType.POLICYGROUP
|| profile.configType == EConfigType.PROXYCHAIN
}
if (invalidMembers.isNotEmpty()) {
toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", ")))
return false
}
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.PROXYCHAIN)
config.remarks = binding.etRemarks.text.toString().trim()
config.proxyChainProfiles = chainMembers.joinToString(",")
config.description = chainMembers.joinToString(" -> ")
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty()
}
MmkvManager.encodeServerConfig(editGuid, config)
if (isRunning) {
SettingsChangeManager.makeRestartService()
}
toastSuccess(R.string.toast_success)
finish()
return true
}
private fun deleteServer(): Boolean {
if (editGuid.isNotEmpty()) {
if (editGuid != MmkvManager.getSelectServer()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
MmkvManager.removeServer(editGuid)
finish()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
} else {
MmkvManager.removeServer(editGuid)
finish()
}
} else {
toast(R.string.toast_action_not_allowed)
}
}
return true
}
private fun addMemberRow() {
if (allRemarks.isEmpty()) {
toast(R.string.toast_none_data)
return
}
memberAdapter.addRow()
}
private fun parseChainMembers(raw: String?): List<String> {
return raw.orEmpty()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
}
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
// Row selection is handled directly by AutoCompleteTextView in the adapter.
}
override fun onRemove(guid: String, position: Int) {
memberAdapter.removeRow(position)
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delButton = menu.findItem(R.id.del_config)
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
}
R.id.save_config -> {
saveServer()
true
}
else -> super.onOptionsItemSelected(item)
}
}
@@ -0,0 +1,136 @@
package com.v2ray.ang.ui
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerProxyChainMemberBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import java.util.Collections
class ServerProxyChainMemberAdapter(
private val members: MutableList<String>,
private val suggestions: List<String>,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<ServerProxyChainMemberAdapter.MemberViewHolder>(), ItemTouchHelperAdapter {
override fun getItemCount(): Int = members.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MemberViewHolder {
return MemberViewHolder(
ItemRecyclerProxyChainMemberBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: MemberViewHolder, position: Int) {
val adapterPos = holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: position
val value = members[position]
holder.binding.tvMemberIndex.text = (position + 1).toString()
val dropdownAdapter = ArrayAdapter(
holder.itemView.context,
android.R.layout.simple_dropdown_item_1line,
suggestions
)
holder.binding.spMemberRemark.setAdapter(dropdownAdapter)
holder.binding.spMemberRemark.threshold = 0
holder.binding.spMemberRemark.setText(value, false)
holder.binding.spMemberRemark.setOnItemClickListener { _, _, selectedIndex, _ ->
if (adapterPos in members.indices) {
members[adapterPos] = suggestions[selectedIndex].trim()
adapterListener?.onRefreshData()
}
}
holder.binding.spMemberRemark.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) return@OnFocusChangeListener
val text = holder.binding.spMemberRemark.text?.toString().orEmpty().trim()
if (adapterPos in members.indices && members[adapterPos] != text) {
members[adapterPos] = text
adapterListener?.onRefreshData()
}
}
holder.binding.spMemberRemark.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
holder.binding.btnMemberDropdown.setOnClickListener {
holder.binding.spMemberRemark.requestFocus()
holder.binding.spMemberRemark.showDropDown()
}
holder.itemView.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
holder.binding.layoutRemove.setOnClickListener {
val removePos = holder.bindingAdapterPosition
if (removePos != RecyclerView.NO_POSITION) {
adapterListener?.onRemove("", removePos)
}
}
}
fun addRow() {
members.add("")
notifyItemInserted(members.lastIndex)
adapterListener?.onRefreshData()
}
fun removeRow(position: Int) {
if (position < 0 || position >= members.size) return
members.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, members.size - position)
adapterListener?.onRefreshData()
}
fun setRemark(position: Int, remark: String) {
if (position < 0 || position >= members.size) return
members[position] = remark
notifyItemChanged(position)
adapterListener?.onRefreshData()
}
fun replaceAll(newMembers: List<String>) {
members.clear()
members.addAll(newMembers)
notifyDataSetChanged()
adapterListener?.onRefreshData()
}
fun getMembers(): List<String> = members.toList()
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
if (fromPosition == toPosition) return true
Collections.swap(members, fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
notifyItemChanged(fromPosition)
notifyItemChanged(toPosition)
return true
}
override fun onItemMoveCompleted() {
adapterListener?.onRefreshData()
}
override fun onItemDismiss(position: Int) {
// Swipe-to-dismiss disabled for this adapter.
}
class MemberViewHolder(val binding: ItemRecyclerProxyChainMemberBinding) :
BaseViewHolder(binding.root), ItemTouchHelperViewHolder
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun onItemSelected() {
itemView.setBackgroundColor(Color.LTGRAY)
}
fun onItemClear() {
itemView.setBackgroundColor(Color.TRANSPARENT)
}
}
}
@@ -6,19 +6,12 @@ import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceFragmentCompat
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequest
import androidx.work.multiprocess.RemoteWorkManager
import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.util.Utils
import java.util.concurrent.TimeUnit
class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -48,16 +41,20 @@ class SettingsActivity : BaseActivity() {
private val fragmentLength by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_LENGTH) }
private val fragmentInterval by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_INTERVAL) }
private val autoUpdateCheck by lazy { findPreference<CheckBoxPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE) }
private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
private val enableLocalProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ENABLE_LOCAL_PROXY) }
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
private val socksUsername by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_USERNAME) }
private val socksPassword by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PASSWORD) }
private val socksEnableUdp by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_SOCKS_ENABLE_UDP) }
private val proxySharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PROXY_SHARING) }
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
// Use MMKV as the storage backend for all Preferences
// This prevents inconsistencies between SharedPreferences and MMKV
@@ -90,15 +87,6 @@ class SettingsActivity : BaseActivity() {
true
}
autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as Boolean
autoUpdateCheck?.isChecked = value
autoUpdateInterval?.isEnabled = value
autoUpdateInterval?.text?.toLongEx()?.let {
if (newValue) configureUpdateTask(it) else cancelUpdateTask()
}
true
}
mode?.setOnPreferenceChangeListener { pref, newValue ->
val valueStr = newValue.toString()
(pref as? ListPreference)?.let { lp ->
@@ -115,6 +103,11 @@ class SettingsActivity : BaseActivity() {
true
}
enableLocalProxy?.setOnPreferenceChangeListener { _, newValue ->
updateEnableLocalProxy(newValue as Boolean)
true
}
dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
updateDynamicSocksPort(newValue as Boolean)
true
@@ -174,15 +167,15 @@ class SettingsActivity : BaseActivity() {
// Initialize mode-dependent UI states
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
// Initialize local proxy state
updateEnableLocalProxy(MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true))
// Initialize mux-dependent UI states
updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false))
// Initialize fragment-dependent UI states
updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false))
// Initialize auto-update interval state
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
updateDynamicSocksPort(MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false))
}
@@ -220,29 +213,6 @@ class SettingsActivity : BaseActivity() {
vpnDns?.isEnabled = !enabled
}
private fun configureUpdateTask(interval: Long) {
val rw = RemoteWorkManager.getInstance(AngApplication.application)
rw.cancelUniqueWork(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
rw.enqueueUniquePeriodicWork(
AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
PeriodicWorkRequest.Builder(
SubscriptionUpdater.UpdateTask::class.java,
interval,
TimeUnit.MINUTES
)
.apply {
setInitialDelay(interval, TimeUnit.MINUTES)
}
.build()
)
}
private fun cancelUpdateTask() {
val rw = RemoteWorkManager.getInstance(AngApplication.application)
rw.cancelUniqueWork(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
}
private fun updateMux(enabled: Boolean) {
muxConcurrency?.isEnabled = enabled
muxXudpConcurrency?.isEnabled = enabled
@@ -276,12 +246,44 @@ class SettingsActivity : BaseActivity() {
}
private fun updateDynamicSocksPort(enabled: Boolean) {
socksPort?.isEnabled = !enabled
socksPort?.isEnabled = (enableLocalProxy?.isChecked == true) && !enabled
}
private fun updateEnableLocalProxy(enabled: Boolean) {
val dynamic = MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
socksPort?.isEnabled = enabled && !dynamic
dynamicSocksPort?.isEnabled = enabled
socksUsername?.isEnabled = enabled
socksPassword?.isEnabled = enabled
socksEnableUdp?.isEnabled = enabled
proxySharing?.isEnabled = enabled
if (!enabled) {
if (appendHttpProxy?.isChecked == true) {
appendHttpProxy?.isChecked = false
MmkvManager.encodeSettings(AppConfig.PREF_APPEND_HTTP_PROXY, false)
}
appendHttpProxy?.isEnabled = false
} else {
val vpn = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) == VPN
appendHttpProxy?.isEnabled = vpn
}
}
private fun updateHevTunSettings(enabled: Boolean) {
hevTunLogLevel?.isEnabled = enabled
hevTunRwTimeout?.isEnabled = enabled
if (enabled) {
if (enableLocalProxy?.isChecked == false) {
enableLocalProxy?.isChecked = true
MmkvManager.encodeSettings(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
}
enableLocalProxy?.isEnabled = false
} else {
enableLocalProxy?.isEnabled = true
}
updateEnableLocalProxy(enableLocalProxy?.isChecked == true)
}
}
@@ -15,6 +15,7 @@ import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -51,6 +52,7 @@ class SubEditActivity : BaseActivity() {
binding.etFilter.text = Utils.getEditable(subItem.filter)
binding.chkEnable.isChecked = subItem.enabled
binding.autoUpdateCheck.isChecked = subItem.autoUpdate
binding.etUpdateInterval.text = Utils.getEditable(subItem.updateInterval.toString())
binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
@@ -65,6 +67,7 @@ class SubEditActivity : BaseActivity() {
binding.etUrl.text = null
binding.etFilter.text = null
binding.chkEnable.isChecked = true
binding.etUpdateInterval.text = null
binding.etPreProfile.text = null
binding.etNextProfile.text = null
return true
@@ -82,6 +85,27 @@ class SubEditActivity : BaseActivity() {
subItem.filter = binding.etFilter.text.toString()
subItem.enabled = binding.chkEnable.isChecked
subItem.autoUpdate = binding.autoUpdateCheck.isChecked
val intervalInput = binding.etUpdateInterval.text.toString().trim()
val intervalMinutes = intervalInput.toLongOrNull()
if (subItem.autoUpdate) {
// autoUpdate is enabled: interval must be valid
if (intervalMinutes == null) {
// field is empty, reset to default
subItem.updateInterval = SubscriptionItem().updateInterval
} else if (intervalMinutes < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
toast(R.string.toast_invalid_update_interval)
return false
} else {
subItem.updateInterval = intervalMinutes
}
} else {
// autoUpdate is disabled: save only if the value is valid, otherwise keep the existing value
if (intervalMinutes != null && intervalMinutes >= AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
subItem.updateInterval = intervalMinutes
}
}
subItem.prevProfile = binding.etPreProfile.text.toString()
subItem.nextProfile = binding.etNextProfile.text.toString()
subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
@@ -105,6 +129,7 @@ class SubEditActivity : BaseActivity() {
}
MmkvManager.encodeSubscription(editSubId, subItem)
SubscriptionUpdater.syncOne(subId = editSubId)
toastSuccess(R.string.toast_success)
finish()
return true
@@ -3,6 +3,9 @@ package com.v2ray.ang.util
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.net.IDN
@@ -14,9 +17,6 @@ import java.net.Proxy
import java.net.URI
import java.net.URL
import java.util.concurrent.TimeUnit
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
object HttpUtil {
@@ -172,6 +172,9 @@ object HttpUtil {
.get()
.header("User-agent", finalUserAgent)
.header("Connection", "close")
applyEmbeddedBasicAuthHeader(currentUrl, requestBuilder)
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
}
@@ -203,6 +206,20 @@ object HttpUtil {
throw IOException("Too many redirects")
}
private fun applyEmbeddedBasicAuthHeader(rawUrl: String, requestBuilder: Request.Builder) {
val parsed = runCatching { URL(rawUrl) }.getOrNull() ?: return
parsed.userInfo?.let { userInfo ->
val colon = userInfo.indexOf(':')
val user = runCatching {
Utils.decodeURIComponent(if (colon >= 0) userInfo.substring(0, colon) else userInfo)
}.getOrDefault(if (colon >= 0) userInfo.substring(0, colon) else userInfo)
val pass = runCatching {
Utils.decodeURIComponent(if (colon >= 0) userInfo.substring(colon + 1) else "")
}.getOrDefault(if (colon >= 0) userInfo.substring(colon + 1) else "")
requestBuilder.header("Authorization", Credentials.basic(user, pass))
}
}
private fun buildOkHttpClient(
timeout: Int,
httpPort: Int,
@@ -290,4 +307,3 @@ object HttpUtil {
}
}
}
@@ -3,9 +3,11 @@ package com.v2ray.ang.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.service.V2RayTestService
import com.v2ray.ang.service.CoreTestService
import java.io.Serializable
object MessageUtil {
@@ -42,9 +44,26 @@ object MessageUtil {
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, V2RayTestService::class.java)
intent.component = ComponentName(ctx, CoreTestService::class.java)
intent.putExtra("content", message)
ctx.startService(intent)
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(ctx, intent)
} else {
ctx.startService(intent)
}
}
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
// Do not wake up service just to cancel; stop only if it is already running.
ctx.stopService(intent)
}
else -> {
ctx.startService(intent)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
}
@@ -0,0 +1,162 @@
package com.v2ray.ang.util
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.NotificationChannelType
/**
* Unified notification helper for different notification channels.
* Supports both regular notifications and foreground service notifications.
*
* Performance: NotificationManager is cached. Builder is created once per update.
* Safe for high-frequency updates (100+ times/second).
*/
object NotificationHelper {
// Cached instances for performance
private var cachedNotificationManager: NotificationManager? = null
private val builderCache = mutableMapOf<Int, NotificationCompat.Builder>()
/**
* Notify with a regular notification (non-foreground).
*
* @param channelType The notification channel type (defines channelId, notificationId, etc.)
* @param context The context for building the notification
* @param title The notification title
* @param content The notification content text
*/
fun notify(
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
) {
ensureChannelCreated(channelType, context)
val notificationManager = getNotificationManager(context)
val builder = buildNotificationBuilder(channelType, context, title, content)
notificationManager.notify(channelType.notificationId, builder.build())
}
/**
* Update an existing notification's content.
* Optimized for high-frequency updates (100+/sec).
* Reuses cached Builder to minimize allocation overhead.
*
* @param channelType The notification channel type
* @param context The context
* @param content The new content text
*/
fun updateNotification(
channelType: NotificationChannelType,
context: Context,
content: String
) {
val notificationManager = getNotificationManager(context)
// Get or create builder from cache
val builder = builderCache.getOrPut(channelType.notificationId) {
buildNotificationBuilder(channelType, context, "", content)
}
// Update only the content text (fast operation)
builder.setContentText(content)
notificationManager.notify(channelType.notificationId, builder.build())
}
/**
* Start a foreground service with a notification.
*
* @param service The service to set as foreground
* @param channelType The notification channel type
* @param title The notification title
* @param content The notification content text
*/
fun startForeground(
service: Service,
channelType: NotificationChannelType,
title: String,
content: String
) {
ensureChannelCreated(channelType, service)
val builder = buildNotificationBuilder(channelType, service, title, content)
service.startForeground(channelType.notificationId, builder.build())
}
/**
* Stop the foreground notification for a service.
*
* @param service The service to stop foreground on
*/
fun stopForeground(service: Service) {
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
}
/**
* Cancel a notification and clean up cached builder.
*
* @param channelType The notification channel type
* @param context The context
*/
fun cancel(
channelType: NotificationChannelType,
context: Context
) {
getNotificationManager(context).cancel(channelType.notificationId)
builderCache.remove(channelType.notificationId) // Clean up cache
}
// ====== Private helper methods ======
private fun getNotificationManager(context: Context): NotificationManager {
if (cachedNotificationManager == null) {
cachedNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
return cachedNotificationManager!!
}
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
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
}
notificationManager.createNotificationChannel(channel)
}
private fun buildNotificationBuilder(
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
): NotificationCompat.Builder {
val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
channelType.channelId
} else {
""
}
val displayTitle = title.ifEmpty { context.getString(R.string.app_name) }
return NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_name)
.setContentTitle(displayTitle)
.setContentText(content)
.setOngoing(false)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
}
}
@@ -33,7 +33,7 @@ object PackageUidResolver {
private fun resolveUid(context: Context, packageName: String): String? {
// Special token for connections whose UID cannot be resolved (mapped to -1)
if (packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
val uid = "-1"
val uid = "-1"
LogUtil.d(AppConfig.TAG, "Special package: $packageName -> UID: $uid")
return uid
}
@@ -488,6 +488,16 @@ object Utils {
throw IOException("no free port found")
}
/**
* Find a random free port.
*
* @return A random free port.
* @throws IOException If no free port is found.
*/
fun findRandomFreePort(): Int {
return ServerSocket(0).use { it.localPort }
}
/**
* Check if a string is a valid subscription URL.
*
@@ -19,7 +19,6 @@ import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.extension.matchesPattern
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
@@ -222,7 +221,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG,
key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = subscriptionId,
serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList()
)
@@ -457,7 +456,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
AppConfig.MSG_STATE_START_FAILURE -> {
getApplication<AngApplication>().toastError(R.string.toast_services_failure)
val errorMessage = intent.getStringExtra("content")
if (!errorMessage.isNullOrBlank()) {
getApplication<AngApplication>().toastError(errorMessage)
} else {
getApplication<AngApplication>().toastError(R.string.toast_services_failure)
}
isRunning.value = false
}
@@ -470,9 +474,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> {
val resultPair = intent.serializable<Pair<String, Long>>("content") ?: return
MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
updateListAction.value = getPosition(resultPair.first)
val content = intent.getStringExtra("content")
updateListAction.value = getPosition(content ?: "")
}
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.ServerProxyChainActivity">
<androidx.core.widget.NestedScrollView
android:id="@+id/main_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_remarks"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<EditText
android:id="@+id/et_remarks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:hint="@string/server_lab_remarks"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_proxy_chain_members"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_proxy_chain_members"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_recycler_proxy_chain_member" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_proxy_chain_member"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_margin="@dimen/padding_spacing_dp16"
android:contentDescription="@string/menu_item_add_config"
android:src="@drawable/ic_add_24dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</RelativeLayout>
@@ -146,7 +146,6 @@
android:layout_weight="1.0"
android:text="@string/sub_auto_update" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/auto_update_check"
android:layout_width="wrap_content"
@@ -157,6 +156,26 @@
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_pref_auto_update_interval" />
<EditText
android:id="@+id/et_update_interval"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
tools:ignore="TextFields" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/item_bg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/info_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp8">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp8">
<TextView
android:id="@+id/tv_member_index"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/padding_spacing_dp8"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<AutoCompleteTextView
android:id="@+id/sp_member_remark"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:hint="@string/server_proxy_chain_member_unselected"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
tools:ignore="NestedWeights" />
<ImageView
android:id="@+id/btn_member_dropdown"
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:layout_marginStart="@dimen/padding_spacing_dp8"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/server_proxy_chain_pick_members"
android:focusable="true"
app:srcCompat="@drawable/ic_arrow_drop_down" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp8">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
+2 -22
View File
@@ -134,30 +134,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:nextFocusDown="@+id/sp_ech_force_query" />
android:nextFocusDown="@+id/et_pinned_ca256" />
</LinearLayout>
<LinearLayout
android:id="@+id/lay_ech_force_query"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_ech_force_query" />
<Spinner
android:id="@+id/sp_ech_force_query"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8"
android:entries="@array/ech_force_query_value" />
</LinearLayout>
<LinearLayout
android:id="@+id/lay_pinned_ca256"
android:layout_width="match_parent"
@@ -175,7 +155,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:nextFocusDown="@+id/sp_ech_force_query" />
android:nextFocusDown="@+id/et_public_key" />
</LinearLayout>
@@ -96,6 +96,49 @@
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_kcp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_kcp_mtu" />
<EditText
android:id="@+id/et_kcp_mtu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_kcp_tti" />
<EditText
android:id="@+id/et_kcp_tti"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_extra"
android:layout_width="match_parent"
@@ -118,25 +161,54 @@
android:minLines="4" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_fm"
<LinearLayout
android:id="@+id/layout_fm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_final_mask" />
<EditText
android:id="@+id/et_fm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
android:gravity="top"
android:inputType="textMultiLine"
android:maxLines="20"
android:minLines="4" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_final_mask" />
<LinearLayout
android:id="@+id/layout_browser_dialer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<EditText
android:id="@+id/et_fm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:inputType="textMultiLine"
android:maxLines="20"
android:minLines="4" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_browser_dialer" />
<Spinner
android:id="@+id/sp_browser_dialer_mode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp16"
android:entries="@array/browser_dialer_mode" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp4"
android:text="@string/server_lab_browser_dialer_tip"
android:textColor="@android:color/darker_gray"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
@@ -28,6 +28,10 @@
android:id="@+id/import_manually_policy_group"
android:title="@string/menu_item_import_config_policy_group"
app:showAsAction="never" />
<item
android:id="@+id/import_manually_proxy_chain"
android:title="@string/menu_item_import_config_proxy_chain"
app:showAsAction="never" />
<item
android:id="@+id/import_manually_vmess"
android:title="@string/menu_item_import_config_manually_vmess"
+15 -1
View File
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">استيراد التكوين من الحافظة</string>
<string name="menu_item_import_config_local">Import config from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">الكتابة يدويًا [VMess]</string>
<string name="menu_item_import_config_manually_vless">الكتابة يدويًا [VLESS]</string>
<string name="menu_item_import_config_manually_ss">الكتابة يدويًا [Shadowsocks]</string>
@@ -89,6 +90,8 @@
<string name="server_lab_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="toast_success">نجاح</string>
<string name="toast_failure">فشل</string>
<string name="toast_none_data">لا يوجد شيء</string>
@@ -115,7 +118,6 @@
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity -->
@@ -209,6 +211,7 @@
<string name="title_pref_delay_test_url">True delay test url </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_real_ping_concurrency">True delay test concurrency</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -221,6 +224,10 @@
<string name="summary_pref_allow_insecure">عند TLS، الافتراضي هو السماح غير الآمن</string>
<string name="title_pref_socks_port">منفذ بروكسي Local</string>
<string name="title_pref_enable_local_proxy">تفعيل الوكيل المحلي</string>
<string name="summary_pref_enable_local_proxy">When disabled, no SOCKS proxy is available, so subscription updates and similar actions cannot use the proxy</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<string name="summary_pref_socks_enable_udp">مصادقة socks5 ليست آمنة تمامًا عند استخدام UDP</string>
<string name="summary_pref_socks_port">منفذ بروكسي Local</string>
<string name="title_pref_dynamic_socks_port">Enable dynamic local proxy port</string>
<string name="summary_pref_dynamic_socks_port">Generate a random local proxy port each time the inbound is created</string>
@@ -295,6 +302,7 @@
<string name="sub_setting_pre_profile">Previous proxy configuration remarks</string>
<string name="sub_setting_next_profile">Next proxy configuration remarks</string>
<string name="sub_setting_pre_profile_tip">The configuration remarks exists and is unique</string>
<string name="toast_invalid_update_interval">فاصل التحديث غير صالح. الحد الأدنى هو 15 دقيقة.</string>
<string name="title_sub_update">تحديث الاشتراك (أول خطوة)</string>
<string name="title_ping_all_server">Tcping لجميع الإعدادات</string>
<string name="title_real_ping_all_server"> اختبر جميع الإعدادات (3)</string>
@@ -370,6 +378,12 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="server_proxy_chain_members">اعضاء سلسلة الوكيل</string>
<string name="server_proxy_chain_pick_members">اضغط هنا لاختيار عضو</string>
<string name="server_proxy_chain_member_unselected">اختر عضوا</string>
<string name="server_proxy_chain_members_unselected">يرجى اختيار remark لكل صف في السلسلة</string>
<string name="server_proxy_chain_members_insufficient">عدد الاعضاء غير كاف</string>
<string name="server_proxy_chain_members_invalid">اعضاء سلسلة غير صالحين: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
+15 -1
View File
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">ক্লিপবোর্ড থেকে কনফিগারেশন আমদানি করুন</string>
<string name="menu_item_import_config_local">Import config from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">ম্যানুয়ালি টাইপ করুন [VMess]</string>
<string name="menu_item_import_config_manually_vless">ম্যানুয়ালি টাইপ করুন [VLESS]</string>
<string name="menu_item_import_config_manually_ss">ম্যানুয়ালি টাইপ করুন [Shadowsocks]</string>
@@ -88,6 +89,8 @@
<string name="server_lab_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="toast_success">সফল</string>
<string name="toast_failure">ব্যর্থ</string>
<string name="toast_none_data">কোনও তথ্য নেই</string>
@@ -114,7 +117,6 @@
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity -->
@@ -209,6 +211,7 @@
<string name="title_pref_delay_test_url">সঠিক বিলম্ব পরীক্ষা ইউআরএল </string>
<string name="summary_pref_delay_test_url">ইউআরএল</string>
<string name="title_pref_real_ping_concurrency">True delay test concurrency</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -221,6 +224,10 @@
<string name="summary_pref_allow_insecure">যখন TLS, ডিফল্টভাবে allowInsecure</string>
<string name="title_pref_socks_port">Local প্রক্সি পোর্ট</string>
<string name="title_pref_enable_local_proxy">লোকাল প্রক্সি সক্ষম করুন</string>
<string name="summary_pref_enable_local_proxy">When disabled, no SOCKS proxy is available, so subscription updates and similar actions cannot use the proxy</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<string name="summary_pref_socks_enable_udp">UDP ব্যবহার করার সময় socks5 প্রমাণীকরণ সম্পূর্ণরূপে নিরাপদ নয়</string>
<string name="summary_pref_socks_port">Local প্রক্সি পোর্ট</string>
<string name="title_pref_dynamic_socks_port">Enable dynamic local proxy port</string>
<string name="summary_pref_dynamic_socks_port">Generate a random local proxy port each time the inbound is created</string>
@@ -295,6 +302,7 @@
<string name="sub_setting_pre_profile">Previous proxy configuration remarks</string>
<string name="sub_setting_next_profile">Next proxy configuration remarks</string>
<string name="sub_setting_pre_profile_tip">The configuration remarks exists and is unique</string>
<string name="toast_invalid_update_interval">আপডেটের বিরতি সঠিক নয়। সর্বনিম্ন ১৫ মিনিট।</string>
<string name="title_sub_update">সাবস্ক্রিপশন আপডেট</string>
<string name="title_ping_all_server">সব কনফিগারেশন TCPing</string>
<string name="title_real_ping_all_server">সব কনফিগারেশন প্রকৃত বিলম্ব</string>
@@ -369,6 +377,12 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>
<string name="server_proxy_chain_member_unselected">Select a member</string>
<string name="server_proxy_chain_members_unselected">Please select member for each chain row</string>
<string name="server_proxy_chain_members_insufficient">Insufficient members</string>
<string name="server_proxy_chain_members_invalid">Invalid chain members: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">و من ٱووردن کانفیگ ز کلیپ بورد</string>
<string name="menu_item_import_config_local">و من ٱووردن کانفیگ ز مهلی</string>
<string name="menu_item_import_config_policy_group">ٱووردن [بونکۊ سیاست]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">هؽل دستی[VMess]</string>
<string name="menu_item_import_config_manually_vless">هؽل دستی[VLESS]</string>
<string name="menu_item_import_config_manually_ss">هؽل دستی[Shadowsocks]</string>
@@ -88,6 +89,8 @@
<string name="server_lab_reserved">Reserved(اختیاری، وا کاما ز یک جوڌا ابۊن)</string>
<string name="server_lab_local_address">نشۊوی مهلی (اختیاری IPv4/IPv6، وا کاما ز یک جوڌا ابۊن)</string>
<string name="server_lab_local_mtu">Mtu(اختیاری، پؽش فرز 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پؽش فرز هسته 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پؽش فرز هسته 50)</string>
<string name="toast_success">وا مووفقیت ٱنجوم وابی</string>
<string name="toast_failure">شکست خرد</string>
<string name="toast_none_data">هیچ داده ای وۊجۊڌ نڌاره</string>
@@ -112,9 +115,8 @@
<string name="server_lab_bandwidth_up">وا روء رئڌن پئنا باند (واهڌ)</string>
<string name="server_lab_xhttp_mode">هالت XHTTP</string>
<string name="server_lab_xhttp_extra">XHTTP Extra خام JSON، قالوو: { XHTTPObject }</string>
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
<string name="server_lab_final_mask">finalMask خام JSON، قالوو: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">نومگه کانفیگ Ech</string>
<string name="server_lab_ech_force_query">پورس وو جۊ اجباری Ech</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity -->
@@ -135,8 +137,8 @@
<string name="menu_item_search">پیتینیڌن</string>
<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">هالت Bypass</string>
<string name="msg_enter_keywords">کلمه کلیدی ن بزنین</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>
@@ -151,7 +153,7 @@
<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">پوی وولاتی: برنومه واجۊری بیڌه پروکسی هڌ، منپیز موستقیم بؽ نشووه هڌ. هالت دور زیڌن: برنومه نشووک ناڌه موستقیمن منپیز هڌ، پروکسی نشووک زیڌه نؽڌ. گۊزینه پسند خوتکار برنومه پروکسی من نومگه</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>
@@ -187,10 +189,10 @@
<string name="summary_pref_fake_dns_enabled">DNS مهلی نشۊویا IP جئلی ن وورگنه (زل تر، ٱما گاشڌ من یقرد ز برنومه یل کار نکونه)</string>
<string name="title_pref_ipv6_enabled">ره وندن IPv6</string>
<string name="summary_pref_ipv6_enabled">نشۊویا IPv6 وو تورا IPv6 ن ز رابط VPN ره وند کۊنین</string>
<string name="summary_pref_ipv6_enabled">نشۊویا IPv6 وو تورا IPv6 ن ز رابت VPN ره ونین</string>
<string name="title_pref_prefer_ipv6">ترجی داڌن IPv6</string>
<string name="summary_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="summary_pref_remote_dns">DNS</string>
@@ -209,6 +211,7 @@
<string name="title_pref_delay_test_url">نشۊوی اینترنتی آزمایش تئخیر واقعی</string>
<string name="summary_pref_delay_test_url">نشۊوی اینترنتی</string>
<string name="title_pref_real_ping_concurrency">هموورگی آزمایش تئخیر واقعی</string>
<string name="title_pref_ip_api_url">نشۊوی اینترنتی آزمایش دووسمندیا منپیز هیم سکویی</string>
<string name="summary_pref_ip_api_url">نشۊوی اینترنتی</string>
@@ -221,13 +224,17 @@
<string name="summary_pref_allow_insecure">مجال و کار بوردن TLS ب تۉر پؽش فرز، موجوز نا ٱمن فعال هڌ.</string>
<string name="title_pref_socks_port">پورت پروکسی مهلی</string>
<string name="title_pref_enable_local_proxy">ره وندن پروکسی مهلی</string>
<string name="summary_pref_enable_local_proxy">هرسا قیرفعال بۊ، هیچ پروکسی SOCKS من دسرس نؽ، سی دل هیمو ورۊ رسۊوی یل اشتراک وو کارا جۊر یو نترن ز پروکسی استفاڌه کونن.</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<string name="summary_pref_socks_enable_udp">ائراز هۊویت socks5 مجال استفاڌه ز UDP کاملن ایمن نؽ</string>
<string name="summary_pref_socks_port">پورت پروکسی مهلی</string>
<string name="title_pref_dynamic_socks_port">Enable dynamic local proxy port</string>
<string name="summary_pref_dynamic_socks_port">Generate a random local proxy port each time the inbound is created</string>
<string name="title_pref_socks_username">Local proxy username (optional)</string>
<string name="summary_pref_socks_username">Username</string>
<string name="title_pref_socks_password">Local proxy password (optional)</string>
<string name="summary_pref_socks_password">Password</string>
<string name="title_pref_dynamic_socks_port">ره وندن پورت پروکسی مهلی پۊیا</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>
@@ -245,7 +252,7 @@
<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>
<string name="summary_pref_feedback">فشناڌن منشڌ یا گوزارش موشکلا من Github</string>
@@ -295,6 +302,7 @@
<string name="sub_setting_pre_profile">نوم موستعار پروکسی دیندایی</string>
<string name="sub_setting_next_profile">نوم موستعار پروکسی نیایی</string>
<string name="sub_setting_pre_profile_tip">موتمعن بۊ ک نوم موستعار هڌس وو جۊرس نی</string>
<string name="toast_invalid_update_interval">فاسله ورۊ کردن نا موئتبر هڌ. اقل مقدار 15 دؽقه هڌ.</string>
<string name="title_sub_update">ورۊ کردن اشتراک بونکۊ سکویی</string>
<string name="title_ping_all_server">Tcping کانفیگا بونکۊ سکویی</string>
<string name="title_real_ping_all_server">تئخیر واقعی کانفیگا بونکۊ سکویی</string>
@@ -310,9 +318,9 @@
<string name="title_update_config_count">ورۊ کردن %d کانفیگ</string>
<string name="title_update_subscription_result">%1$d کانفیگ ورۊ وابی (مووفق: %2$d، شکست خرده: %3$d، رڌ وابیڌه: %4$d)</string>
<string name="title_update_subscription_no_subscription">بؽ اشتراک</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
<string name="toast_fragment_not_available">Unable to locate current view</string>
<string name="title_locate_selected_config">Locate the selected config</string>
<string name="toast_server_not_found_in_group">سرور پسند وابیڌه من بونکۊ سکویی نجۊرست</string>
<string name="toast_fragment_not_available">نتره نما سکویی ن بجۊره</string>
<string name="title_locate_selected_config">کانفیگ پسند وابیڌه ن بجۊرین</string>
<string name="tasker_start_service">ره وندن خدمات</string>
<string name="tasker_setting_confirm">قوۊل</string>
@@ -320,7 +328,7 @@
<!-- RoutingSettingActivity -->
<string name="routing_settings_domain_strategy">نشقه دامنه</string>
<string name="routing_settings_title">سامووا تور جوستن</string>
<string name="routing_settings_tips">وا کاما ز یک جوڌا ابۊن (،)؛ یکی را وارد کنید: domain، ip یا process</string>
<string name="routing_settings_tips">وا کاما ز یک جوڌا ابۊن (,)؛ یکی ن بزنین: domain، ip یا process</string>
<string name="routing_settings_save">زفت کردن</string>
<string name="routing_settings_delete">روفتن</string>
<string name="routing_settings_rule_title">سامووا قانۉݩ تور جوستن</string>
@@ -369,6 +377,12 @@
<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="server_proxy_chain_members">اعضای زنجیره پروکسی</string>
<string name="server_proxy_chain_pick_members">اینجا بزنین تا عضو اِنتخاب اکه</string>
<string name="server_proxy_chain_member_unselected">یه عضو اِنتخاب کۊنین</string>
<string name="server_proxy_chain_members_unselected">لطفا سی هر ردیو زنجیره، remark اِنتخاب کۊنین</string>
<string name="server_proxy_chain_members_insufficient">تعداد عضوها کافی نیس</string>
<string name="server_proxy_chain_members_invalid">اعضای نامعتبر زنجیره: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">لادراری گرؽڌن &amp; وورگندن</string>
+15 -1
View File
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">کانفیگ را از کلیپ ‌بورد وارد کنید</string>
<string name="menu_item_import_config_local">کانفیگ را از محلی وارد کنید</string>
<string name="menu_item_import_config_policy_group">اضافه کردن [گروه خط مشی]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">تایپ دستی[VMESS]</string>
<string name="menu_item_import_config_manually_vless">تایپ دستی[VLESS]</string>
<string name="menu_item_import_config_manually_ss">تایپ دستی[SHADOWSOCKS]</string>
@@ -88,6 +89,8 @@
<string name="server_lab_reserved">Reserved (اختیاری، جدا شده با کاما)</string>
<string name="server_lab_local_address">آدرس محلی (IPv4/IPv6 اختیاری، جدا شده با کاما)</string>
<string name="server_lab_local_mtu">MTU (اختیاری، پیش‌فرض 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (اختیاری، پیش‌فرض هسته 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (اختیاری، پیش‌فرض هسته 50)</string>
<string name="toast_success">با موفقیت انجام شد</string>
<string name="toast_failure">شکست</string>
<string name="toast_none_data">هیچ داده ای وجود ندارد</string>
@@ -114,7 +117,6 @@
<string name="server_lab_xhttp_extra">خام JSON XHTTP Extra، قالب: { XHTTPObject }</string>
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">لیست کانفیگ Ech</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity -->
@@ -207,6 +209,7 @@
<string name="title_pref_delay_test_url">آدرس اینترنتی آزمایش تاخیر واقعی کانفیگ ها </string>
<string name="summary_pref_delay_test_url">URL</string>
<string name="title_pref_real_ping_concurrency">تعداد همزمانی تست تأخیر واقعی</string>
<string name="title_pref_ip_api_url">تست اطلاعات اتصال فعلی url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -219,6 +222,10 @@
<string name="summary_pref_allow_insecure">هنگام استفاده از TLS، به طور پیش‌ فرض مجوز ناامن فعال است.</string>
<string name="title_pref_socks_port">پورت پروکسی محلی</string>
<string name="title_pref_enable_local_proxy">فعال‌سازی پروکسی محلی</string>
<string name="summary_pref_enable_local_proxy">When disabled, no SOCKS proxy is available, so subscription updates and similar actions cannot use the proxy</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<string name="summary_pref_socks_enable_udp">احراز هویت socks5 هنگام استفاده از UDP کاملاً ایمن نیست</string>
<string name="summary_pref_socks_port">پورت پروکسی محلی</string>
<string name="title_pref_dynamic_socks_port">Enable dynamic local proxy port</string>
<string name="summary_pref_dynamic_socks_port">Generate a random local proxy port each time the inbound is created</string>
@@ -292,6 +299,7 @@
<string name="sub_setting_pre_profile">نام مستعار پروکسی قبلی</string>
<string name="sub_setting_next_profile">نام مستعار پروکسی بعدی</string>
<string name="sub_setting_pre_profile_tip">لطفاً مطمئن شوید که نام مستعار وجود دارد و منحصر به فرد است</string>
<string name="toast_invalid_update_interval">بازه بروزرسانی نامعتبر است. حداقل مقدار 15 دقیقه است.</string>
<string name="title_sub_update">به‌روزرسانی گروه فعلی اشتراک</string>
<string name="title_ping_all_server">TCPING کانفیگ های گروه فعلی</string>
<string name="title_real_ping_all_server">تاخیر واقعی کانفیگ های گروه فعلی</string>
@@ -366,6 +374,12 @@
<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="server_proxy_chain_members">اعضای زنجیره پروکسی</string>
<string name="server_proxy_chain_pick_members">برای انتخاب عضو اینجا بزنید</string>
<string name="server_proxy_chain_member_unselected">یک عضو را انتخاب کنید</string>
<string name="server_proxy_chain_members_unselected">لطفا برای هر ردیف زنجیره remark انتخاب کنید</string>
<string name="server_proxy_chain_members_insufficient">تعداد اعضا کافی نیست</string>
<string name="server_proxy_chain_members_invalid">اعضای نامعتبر زنجیره: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">پشتیبان‌گیری و بازیابی</string>
+32 -14
View File
@@ -2,7 +2,7 @@
<resources>
<string name="app_widget_name">v2rayNG</string>
<string name="app_tile_name">v2rayNG</string>
<string name="app_tile_first_use">Первое использование этой функции, пожалуйста, используйте приложение, чтобы добавить сервер</string>
<string name="app_tile_first_use">Первое использование этой функции, пожалуйста, используйте приложение, чтобы добавить профиль</string>
<string name="navigation_drawer_open">Открыть панель навигации</string>
<string name="navigation_drawer_close">Закрыть панель навигации</string>
<string name="migration_success">Успешный перенос данных!</string>
@@ -30,14 +30,15 @@
<string name="menu_item_import_config_clipboard">Импорт из буфера обмена</string>
<string name="menu_item_import_config_local">Импорт из файла</string>
<string name="menu_item_import_config_policy_group">Добавить группу политик</string>
<string name="menu_item_import_config_manually_vmess">Ручной ввод VMess</string>
<string name="menu_item_import_config_manually_vless">Ручной ввод VLESS</string>
<string name="menu_item_import_config_manually_ss">Ручной ввод Shadowsocks</string>
<string name="menu_item_import_config_manually_socks">Ручной ввод SOCKS</string>
<string name="menu_item_import_config_manually_http">Ручной ввод HTTP</string>
<string name="menu_item_import_config_manually_trojan">Ручной ввод Trojan</string>
<string name="menu_item_import_config_manually_wireguard">Ручной ввод WireGuard</string>
<string name="menu_item_import_config_manually_hysteria2">Ручной ввод Hysteria2</string>
<string name="menu_item_import_config_proxy_chain">Добавить цепочку прокси</string>
<string name="menu_item_import_config_manually_vmess">Добавить VMess</string>
<string name="menu_item_import_config_manually_vless">Добавить VLESS</string>
<string name="menu_item_import_config_manually_ss">Добавить Shadowsocks</string>
<string name="menu_item_import_config_manually_socks">Добавить SOCKS</string>
<string name="menu_item_import_config_manually_http">Добавить HTTP</string>
<string name="menu_item_import_config_manually_trojan">Добавить Trojan</string>
<string name="menu_item_import_config_manually_wireguard">Добавить WireGuard</string>
<string name="menu_item_import_config_manually_hysteria2">Добавить Hysteria2</string>
<string name="del_config_comfirm">Подтверждаете удаление?</string>
<string name="del_invalid_config_comfirm">Выполните проверку перед удалением! Подтверждаете удаление?</string>
<string name="server_lab_remarks">Название</string>
@@ -69,7 +70,7 @@
<string name="server_lab_stream_security">TLS</string>
<string name="server_lab_stream_fingerprint">Отпечаток</string>
<string name="server_lab_stream_alpn">ALPN</string>
<string name="server_lab_allow_insecure">Разрешать небезопасные</string>
<string name="server_lab_allow_insecure">Разрешать небезопасные соединения</string>
<string name="server_lab_sni">SNI</string>
<string name="server_lab_address3">Адрес</string>
<string name="server_lab_port3">Порт</string>
@@ -88,6 +89,8 @@
<string name="server_lab_reserved">Reserved (необязательно, через запятую)</string>
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (необязательно, по умолчанию 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (необязательно, по умолчанию 50)</string>
<string name="toast_success">Успешно</string>
<string name="toast_failure">Ошибка</string>
<string name="toast_none_data">Ничего нет</string>
@@ -113,9 +116,10 @@
<string name="server_lab_xhttp_mode">Режим XHTTP</string>
<string name="server_lab_xhttp_extra">Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string>
<string name="server_lab_final_mask">Необработанный JSON FinalMask, формат: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_ech_config_list">ECHConfigList</string>
<string name="server_lab_pinned_ca256">Отпечаток сертификата (SHA-256)</string>
<string name="server_lab_browser_dialer">Использовать переадресацию браузера</string>
<string name="server_lab_browser_dialer_tip">Поддерживаются только исходящие соединения XHTTP (packet-up) и WS; настройки, связанные с TLS, могут быть проигнорированы или конфликтовать</string>
<!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">Невозможно скопировать файл, используйте файловый менеджер</string>
@@ -143,6 +147,7 @@
<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="app_picker_unknown_app">Неизвестное приложение (неопознанный UID)</string>
<!-- Preferences -->
<string name="title_settings">Настройки</string>
@@ -210,6 +215,7 @@
<string name="title_pref_delay_test_url">Сервис проверки задержки</string>
<string name="summary_pref_delay_test_url">URL</string>
<string name="title_pref_real_ping_concurrency">Параллельная проверка задержки</string>
<string name="title_pref_ip_api_url">Сервис проверки текущего соединения</string>
<string name="summary_pref_ip_api_url">URL</string>
@@ -222,6 +228,10 @@
<string name="summary_pref_allow_insecure">Для TLS по умолчанию разрешены небезопасные соединения</string>
<string name="title_pref_socks_port">Порт локального прокси</string>
<string name="title_pref_enable_local_proxy">Использовать локальный прокси</string>
<string name="summary_pref_enable_local_proxy">Если отключено, SOCKS-прокси недоступен, поэтому обновления подписки и подобные действия не смогут использовать прокси</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<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">Использовать случайный порт локального прокси при каждом создании соединения</string>
@@ -297,6 +307,7 @@
<string name="sub_setting_pre_profile">Предыдущий профиль прокси</string>
<string name="sub_setting_next_profile">Следующий профиль прокси</string>
<string name="sub_setting_pre_profile_tip">Профиль должен быть уникальным</string>
<string name="toast_invalid_update_interval">Неправильный интервал обновления. Минимум 15 минут.</string>
<string name="title_sub_update">Обновить подписку</string>
<string name="title_ping_all_server">Проверить профили</string>
<string name="title_real_ping_all_server">Проверить задержку профилей</string>
@@ -321,7 +332,7 @@
<!-- RoutingSettingActivity -->
<string name="routing_settings_domain_strategy">Доменная стратегия</string>
<string name="routing_settings_title">Маршрутизация</string>
<string name="routing_settings_tips">Через запятую (,)\nТолько что-то одно: домен, IP или процесс</string>
<string name="routing_settings_tips">Через запятую (,)\nЧто-то одно: домен, IP или процесс</string>
<string name="routing_settings_save">Сохранить</string>
<string name="routing_settings_delete">Очистить</string>
<string name="routing_settings_rule_title">Настройка правил маршрутизации</string>
@@ -334,7 +345,7 @@
<string name="routing_settings_locked">Постоянное (сохранится при импорте правил)</string>
<string name="routing_settings_domain">Домен</string>
<string name="routing_settings_ip">IP</string>
<string name="routing_settings_process">Процесс (название пакета поддерживается только при использовании Xray TUN, включённой функции routeOnly и ОС Android 10+)</string>
<string name="routing_settings_process">Процесс (название пакета; поддерживается только при использовании Xray TUN, включённой функции «Домен только для маршрутизации» и ОС Android 10+)</string>
<string name="routing_settings_port">Порт</string>
<string name="routing_settings_protocol">Протокол</string>
<string name="routing_settings_protocol_tip">[http,tls,bittorrent]</string>
@@ -372,6 +383,13 @@
<string name="title_policy_group_subscription_id">Из группы подписки</string>
<string name="title_policy_group_subscription_filter">Название фильтра</string>
<string name="server_proxy_chain_members">Участники цепочки прокси</string>
<string name="server_proxy_chain_pick_members">Нажмите здесь, чтобы выбрать участника</string>
<string name="server_proxy_chain_member_unselected">Выбор участника</string>
<string name="server_proxy_chain_members_unselected">Выберите участника для каждого звена цепочки</string>
<string name="server_proxy_chain_members_insufficient">Недостаточно участников</string>
<string name="server_proxy_chain_members_invalid">Недопустимые участники цепочки: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Резервное копирование</string>
<string name="title_configuration_backup">Резервирование конфигурации</string>
+15 -1
View File
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">Nhập cấu hình từ Clipboard</string>
<string name="menu_item_import_config_local">Import config from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">Nhập thủ công [VMess]</string>
<string name="menu_item_import_config_manually_vless">Nhập thủ công [VLESS]</string>
<string name="menu_item_import_config_manually_ss">Nhập thủ công [ShadowSocks]</string>
@@ -88,6 +89,8 @@
<string name="server_lab_reserved">Reserved (Không bắt buộc)</string>
<string name="server_lab_local_address">Địa chỉ cục bộ (IPv4 / IPv6, 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 là 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (Không bắt buộc, mặc định core là 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (Không bắt buộc, mặc định core là 50)</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>
@@ -114,7 +117,6 @@
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<!-- UserAssetActivity -->
@@ -209,6 +211,7 @@
<string name="title_pref_delay_test_url">URL kiểm tra độ trễ thực </string>
<string name="summary_pref_delay_test_url">URL</string>
<string name="title_pref_real_ping_concurrency">Số luồng kiểm tra độ trễ thực</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -221,6 +224,10 @@
<string name="summary_pref_allow_insecure">Khi nhập những cấu hình có bảo mật TLS, mặc định sẽ không xác minh chứng chỉ.</string>
<string name="title_pref_socks_port">Cổng Proxy Local</string>
<string name="title_pref_enable_local_proxy">Bật proxy cục bộ</string>
<string name="summary_pref_enable_local_proxy">When disabled, no SOCKS proxy is available, so subscription updates and similar actions cannot use the proxy</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<string name="summary_pref_socks_enable_udp">Xác thực socks5 không hoàn toàn an toàn khi sử dụng UDP</string>
<string name="summary_pref_socks_port">Cổng Proxy Local</string>
<string name="title_pref_dynamic_socks_port">Enable dynamic local proxy port</string>
<string name="summary_pref_dynamic_socks_port">Generate a random local proxy port each time the inbound is created</string>
@@ -295,6 +302,7 @@
<string name="sub_setting_pre_profile">Previous proxy configuration remarks</string>
<string name="sub_setting_next_profile">Next proxy configuration remarks</string>
<string name="sub_setting_pre_profile_tip">The configuration remarks exists and is unique</string>
<string name="toast_invalid_update_interval">Khoảng thời gian cập nhật không hợp lệ. Giá trị tối thiểu là 15 phút.</string>
<string name="title_sub_update">Cập nhật các gói đăng ký</string>
<string name="title_ping_all_server">Ping tất cả máy chủ</string>
<string name="title_real_ping_all_server">Kiểm tra HTTP tất cả máy chủ</string>
@@ -371,6 +379,12 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>
<string name="server_proxy_chain_member_unselected">Select a member</string>
<string name="server_proxy_chain_members_unselected">Please select member for each chain row</string>
<string name="server_proxy_chain_members_insufficient">Insufficient members</string>
<string name="server_proxy_chain_members_invalid">Invalid chain members: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">从剪贴板导入</string>
<string name="menu_item_import_config_local">从本地导入</string>
<string name="menu_item_import_config_policy_group">添加 [策略组]</string>
<string name="menu_item_import_config_proxy_chain">添加 [链式代理]</string>
<string name="menu_item_import_config_manually_vmess">添加 [VMess]</string>
<string name="menu_item_import_config_manually_vless">添加 [VLESS]</string>
<string name="menu_item_import_config_manually_ss">添加 [Shadowsocks]</string>
@@ -88,6 +89,8 @@
<string name="server_lab_reserved">Reserved (可选,逗号隔开)</string>
<string name="server_lab_local_address">本地地址 (可选 IPv4/IPv6,逗号隔开)</string>
<string name="server_lab_local_mtu">Mtu (可选, 默认 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (可选, 默认 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (可选, 默认 50)</string>
<string name="toast_success">成功</string>
<string name="toast_failure">失败</string>
<string name="toast_none_data">没有数据</string>
@@ -114,8 +117,9 @@
<string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string>
<string name="server_lab_final_mask">FinalMask 原始 JSON 格式: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">证书指纹 (SHA-256)</string>
<string name="server_lab_browser_dialer">启用浏览器转发</string>
<string name="server_lab_browser_dialer_tip">仅支持 xhttp (packet-up) 和 ws。与优选域名冲突,utls, alpn, ech 等 TLS 设置将被忽略</string>
<!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">失败, 请使用文件管理器</string>
@@ -153,9 +157,9 @@
<string name="summary_pref_is_booted">开机时自动连接选择的服务器,可能会不成功</string>
<string name="title_pref_auto_remove_invalid_after_test">测试后自动删除无效配置</string>
<string name="summary_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="summary_pref_auto_sort_after_test">测试结果可能不准确</string>
<string name="title_mux_settings">Mux 多路复用 设置</string>
<string name="title_pref_mux_enabled">启用 Mux 多路复用</string>
@@ -206,6 +210,7 @@
<string name="title_pref_delay_test_url">真连接延迟测试网址 </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_real_ping_concurrency">真连接延迟测试并发数量</string>
<string name="title_pref_ip_api_url">当前连接信息测试网址</string>
<string name="summary_pref_ip_api_url">URL</string>
@@ -218,6 +223,10 @@
<string name="summary_pref_allow_insecure">传输层安全选 tls 时,默认跳过证书验证 (allowInsecure)</string>
<string name="title_pref_socks_port">本地代理端口</string>
<string name="title_pref_enable_local_proxy">启用本地代理</string>
<string name="summary_pref_enable_local_proxy">不开启时没有 socks 代理,无法通过代理更新订阅等</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<string name="summary_pref_socks_enable_udp">使用 UDP 时,socks5 认证并不完全安全</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>
@@ -268,7 +277,7 @@
<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_ui_mode_night">深色模式</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">HevTun 日志级别</string>
@@ -293,6 +302,7 @@
<string name="sub_setting_pre_profile">前置代理配置别名</string>
<string name="sub_setting_next_profile">落地代理配置別名</string>
<string name="sub_setting_pre_profile_tip">请确保配置别名存在并唯一</string>
<string name="toast_invalid_update_interval">更新间隔无效,最小值为 15 分钟。</string>
<string name="title_sub_update">更新订阅</string>
<string name="title_ping_all_server">测试配置 Tcping</string>
<string name="title_real_ping_all_server">测试配置真连接</string>
@@ -370,6 +380,12 @@
<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="server_proxy_chain_members">代理链成员</string>
<string name="server_proxy_chain_pick_members">点此选择成员</string>
<string name="server_proxy_chain_member_unselected">请选择成员</string>
<string name="server_proxy_chain_members_unselected">请为链路中的每一行选择成员</string>
<string name="server_proxy_chain_members_insufficient">成员数量不足</string>
<string name="server_proxy_chain_members_invalid">无效的链路成员: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">备份 &amp; 还原</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">從剪貼簿匯入設定</string>
<string name="menu_item_import_config_local">從本地匯入</string>
<string name="menu_item_import_config_policy_group">新增 [策略組]</string>
<string name="menu_item_import_config_proxy_chain">新增 [鍊式代理]</string>
<string name="menu_item_import_config_manually_vmess">新增 [VMess]</string>
<string name="menu_item_import_config_manually_vless">新增 [VLESS]</string>
<string name="menu_item_import_config_manually_ss">新增 [Shadowsocks]</string>
@@ -88,6 +89,8 @@
<string name="server_lab_reserved">Reserved (可選,逗號隔開)</string>
<string name="server_lab_local_address">本機位址 (可選 IPv4/IPv6,逗號隔開)</string>
<string name="server_lab_local_mtu">MTU (可選, 預設 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (可選, 預設 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (可選, 預設 50)</string>
<string name="toast_success">成功</string>
<string name="toast_failure">失敗</string>
<string name="toast_none_data">無資料</string>
@@ -114,7 +117,6 @@
<string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string>
<string name="server_lab_final_mask">FinalMask 原始 JSON 格式: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">證書指紋 (SHA-256)</string>
<!-- UserAssetActivity -->
@@ -154,9 +156,9 @@
<string name="summary_pref_is_booted">開機時自動連線選擇的伺服器,可能會不成功</string>
<string name="title_pref_auto_remove_invalid_after_test">測試後自動刪除無效配置</string>
<string name="summary_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="summary_pref_auto_sort_after_test">測試結果可能不準確</string>
<string name="title_mux_settings">Mux 設定</string>
<string name="title_pref_mux_enabled">啟用 Mux 多路復用</string>
@@ -207,6 +209,7 @@
<string name="title_pref_delay_test_url">真連線延遲測試網址 </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_real_ping_concurrency">真連線延遲測試並發數量</string>
<string name="title_pref_ip_api_url">目前連線資訊測試網址</string>
<string name="summary_pref_ip_api_url">URL</string>
@@ -219,6 +222,10 @@
<string name="summary_pref_allow_insecure">傳輸層安全選 tls 時,預設跳過憑證驗證 (allowInsecure)</string>
<string name="title_pref_socks_port">本地代理埠</string>
<string name="title_pref_enable_local_proxy">啟用本地代理</string>
<string name="summary_pref_enable_local_proxy">未開啟時沒有 socks 代理,無法透過代理更新訂閱等</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<string name="summary_pref_socks_enable_udp">使用 UDP 時,socks5 認證並不完全安全</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>
@@ -268,8 +275,8 @@
<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_ui_settings">介面顯示設定</string>
<string name="title_pref_ui_mode_night">深色模式</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">HevTun 日誌級別</string>
@@ -294,6 +301,7 @@
<string name="sub_setting_pre_profile">前置代理設定檔别名</string>
<string name="sub_setting_next_profile">落地代理設定檔別名</string>
<string name="sub_setting_pre_profile_tip">请确保設定檔别名存在并唯一</string>
<string name="toast_invalid_update_interval">更新間隔無效,最小值為 15 分鐘。</string>
<string name="title_sub_update">更新訂閱</string>
<string name="title_ping_all_server">偵測設定 Tcping</string>
<string name="title_real_ping_all_server">偵測設定真延遲</string>
@@ -309,8 +317,8 @@
<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>
<string name="toast_fragment_not_available">定位所選配置</string>
<string name="title_locate_selected_config">Locate the selected config</string>
<string name="toast_fragment_not_available">無法定位當前視圖</string>
<string name="title_locate_selected_config">定位所選配置</string>
<string name="tasker_start_service">啟動服務</string>
<string name="tasker_setting_confirm">確定</string>
@@ -370,9 +378,15 @@
<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="server_proxy_chain_members">代理鏈成員</string>
<string name="server_proxy_chain_pick_members">點此選擇成員</string>
<string name="server_proxy_chain_member_unselected">請選擇成員</string>
<string name="server_proxy_chain_members_unselected">請為鏈路中的每一列選擇成員</string>
<string name="server_proxy_chain_members_insufficient">成員數量不足</string>
<string name="server_proxy_chain_members_invalid">無效的鏈路成員: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
<string name="title_configuration_backup_restore">備份與恢復</string>
<string name="title_configuration_backup">備份設定</string>
<string name="title_configuration_restore">還原設定</string>
<string name="title_configuration_share">分享設定</string>
@@ -404,7 +418,7 @@
<string-array name="mode_entries">
<item>VPN</item>
<item> Proxy</item>
<item>代理</item>
</string-array>
<string-array name="ui_mode_night">
+6 -6
View File
@@ -117,6 +117,12 @@
<item>VPN</item>
<item>Proxy only</item>
</string-array>
<string-array name="browser_dialer_mode_value" translatable="false">
<item>Disable</item>
<item>OkHttp</item>
<item>WebView</item>
</string-array>
<string-array name="hev_tunnel_loglevel" translatable="false">
<item>error</item>
@@ -215,11 +221,5 @@
<item>2</item>
</string-array>
<string-array name="ech_force_query_value" translatable="false">
<item />
<item>none</item>
<item>half</item>
<item>full</item>
</string-array>
</resources>
+24 -1
View File
@@ -31,6 +31,7 @@
<string name="menu_item_import_config_clipboard">Import from Clipboard</string>
<string name="menu_item_import_config_local">Import from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">Add [VMess]</string>
<string name="menu_item_import_config_manually_vless">Add [VLESS]</string>
<string name="menu_item_import_config_manually_ss">Add [Shadowsocks]</string>
@@ -89,6 +90,8 @@
<string name="server_lab_reserved">Reserved(Optional, separated by commas)</string>
<string name="server_lab_local_address">Local address (optional IPv4/IPv6, separated by commas)</string>
<string name="server_lab_local_mtu">Mtu(optional, default 1420)</string>
<string name="server_lab_kcp_mtu">mKCP MTU (Optional, core default 1350)</string>
<string name="server_lab_kcp_tti">mKCP TTI (Optional, core default 50)</string>
<string name="toast_success">Success</string>
<string name="toast_failure">Failure</string>
<string name="toast_none_data">There is nothing</string>
@@ -115,8 +118,9 @@
<string name="server_lab_xhttp_extra">XHTTP Extra raw JSON, format: { XHTTPObject }</string>
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_pinned_ca256">Certificate fingerprint (SHA-256)</string>
<string name="server_lab_browser_dialer">Enable Browser Dialer</string>
<string name="server_lab_browser_dialer_tip">Only supports xhttp (packet-up) and ws outbound; TLS-related settings may be ignored or conflict</string>
<!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">File copy failed, please use File Manager</string>
@@ -213,6 +217,7 @@
<string name="title_pref_delay_test_url">True delay test url </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_real_ping_concurrency">True delay test concurrency</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -225,6 +230,10 @@
<string name="summary_pref_allow_insecure">When TLS is selected, allow insecure connections by default</string>
<string name="title_pref_socks_port">Local proxy port</string>
<string name="title_pref_enable_local_proxy">Enable local proxy</string>
<string name="summary_pref_enable_local_proxy">When disabled, no SOCKS proxy is available, so subscription updates and similar actions cannot use the proxy</string>
<string name="title_pref_socks_enable_udp">SOCKS5 UDP</string>
<string name="summary_pref_socks_enable_udp">SOCKS5 authentication is not fully secure when using UDP.</string>
<string name="summary_pref_socks_port">Local proxy port</string>
<string name="title_pref_dynamic_socks_port">Enable dynamic local proxy port</string>
<string name="summary_pref_dynamic_socks_port">Generate a random local proxy port each time the inbound is created</string>
@@ -300,6 +309,7 @@
<string name="sub_setting_pre_profile">Previous proxy config remarks</string>
<string name="sub_setting_next_profile">Next proxy config remarks</string>
<string name="sub_setting_pre_profile_tip">The config remarks exist and are unique</string>
<string name="toast_invalid_update_interval">Invalid update interval. Minimum is 15 minutes.</string>
<string name="title_sub_update">Update subscription</string>
<string name="title_ping_all_server">Tcping config</string>
<string name="title_real_ping_all_server">Real delay config</string>
@@ -376,6 +386,13 @@
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>
<string name="server_proxy_chain_member_unselected">Select a member</string>
<string name="server_proxy_chain_members_unselected">Please select member for each chain row</string>
<string name="server_proxy_chain_members_insufficient">Insufficient members</string>
<string name="server_proxy_chain_members_invalid">Invalid chain members: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
<string name="title_configuration_backup">Backup config</string>
@@ -412,6 +429,12 @@
<item>Proxy only</item>
</string-array>
<string-array name="browser_dialer_mode">
<item>Disable</item>
<item>OkHttp</item>
<item>WebView</item>
</string-array>
<string-array name="ui_mode_night">
<item>Follow system</item>
<item>Light</item>
+23 -19
View File
@@ -146,15 +146,20 @@
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_proxy_sharing_enabled"
android:summary="@string/summary_pref_proxy_sharing_enabled"
android:title="@string/title_pref_proxy_sharing_enabled" />
android:key="pref_allow_insecure"
android:title="@string/title_pref_allow_insecure" />
<CheckBoxPreference
android:defaultValue="true"
android:key="pref_enable_local_proxy"
android:summary="@string/summary_pref_enable_local_proxy"
android:title="@string/title_pref_enable_local_proxy" />
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_allow_insecure"
android:summary="@string/summary_pref_allow_insecure"
android:title="@string/title_pref_allow_insecure" />
android:key="pref_proxy_sharing_enabled"
android:summary="@string/summary_pref_proxy_sharing_enabled"
android:title="@string/title_pref_proxy_sharing_enabled" />
<EditTextPreference
android:inputType="number"
@@ -179,6 +184,12 @@
android:summary="@string/summary_pref_socks_password"
android:title="@string/title_pref_socks_password" />
<CheckBoxPreference
android:defaultValue="true"
android:key="pref_socks_enable_udp"
android:summary="@string/summary_pref_socks_enable_udp"
android:title="@string/title_pref_socks_enable_udp" />
<EditTextPreference
android:key="pref_remote_dns"
android:summary="@string/summary_pref_remote_dns"
@@ -267,19 +278,6 @@
android:title="@string/title_pref_fragment_packets" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/title_sub_setting"
app:initialExpandedChildrenCount="0">
<CheckBoxPreference
android:key="pref_auto_update_subscription"
android:summary="@string/summary_pref_auto_update_subscription"
android:title="@string/title_pref_auto_update_subscription" />
<EditTextPreference
android:inputType="number"
android:key="pref_auto_update_interval"
android:summary="1440"
android:title="@string/title_pref_auto_update_interval" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/title_advanced">
@@ -305,6 +303,12 @@
android:summary="@string/summary_pref_delay_test_url"
android:title="@string/title_pref_delay_test_url" />
<EditTextPreference
android:inputType="number"
android:key="pref_real_ping_concurrency"
android:summary="16"
android:title="@string/title_pref_real_ping_concurrency" />
<EditTextPreference
android:key="pref_ip_api_url"
android:summary="@string/summary_pref_ip_api_url"
+1 -1
View File
@@ -1,5 +1,5 @@
[versions]
agp = "9.1.1"
agp = "9.2.1"
desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8"
kotlin = "2.3.10"
+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.3.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists