Compare commits

..

131 Commits

Author SHA1 Message Date
2dust b103e3aec9 up 2.2.3 2026-06-02 18:39:35 +08:00
2dust 19fc65d128 Update allowInsecure deprecation messages and toast 2026-06-02 18:27:37 +08:00
2dust 27cafd1052 Deprecate allowInsecure and show warning toast
https://github.com/2dust/v2rayNG/issues/5717
2026-06-02 17:02:10 +08:00
2dust a155b24c1e up 2.2.2 2026-06-01 16:02:15 +08:00
2dust e805fcf088 Fix
https://github.com/2dust/v2rayNG/issues/5720
2026-06-01 15:55:06 +08:00
2dust 95e37db42e Update AndroidLibXrayLite 2026-06-01 15:05:39 +08:00
2dust fe812c4456 Remove allowInsecure preference and default to secure 2026-06-01 15:04:26 +08:00
fuilloi d5af0fe735 Update build.yml (#5718) 2026-06-01 13:46:07 +08:00
Hossein Abaspanah 9126aa9c9e Update Luri Bakhtiari translation (#5713)
* Update strings.xml

* Update strings new.xml

* Update strings.xml
2026-06-01 13:45:49 +08:00
Skh-web6982 4a763d2ec5 Update kotlin version to 2.3.21 (#5701)
* Update kotlin

* Update kotlin version to 2.3.21
2026-05-31 10:27:26 +08:00
Skh-web6982 08d6de4504 Upgrade Gradle wrapper (#5700) 2026-05-31 10:27:11 +08:00
Alexey 629d410189 fix: allow IPv6 policy group members (#5697) 2026-05-28 20:42:40 +08:00
2dust 39d2513df1 up 2.2.1 2026-05-27 19:59:55 +08:00
2dust 3cb4df474b Keep selected server when replacing subscription
https://github.com/2dust/v2rayNG/pull/5676
2026-05-27 19:53:18 +08:00
fuilloi b64bb92c8c Update MainRecyclerAdapter.kt (#5692)
* Update MainRecyclerAdapter.kt

Add text display for flow, network, and transport layer security.

* Update MainRecyclerAdapter.kt

remove flow
2026-05-27 17:30:22 +08:00
Tanbir Hossen 7f0e2f801d fix: migrate wsSettings host from deprecated headers.Host to independent host field (#5686)
* fix: migrate wsSettings host from deprecated headers.Host to independent host field

fix: migrate wsSettings host out of deprecated headers field

- Add independent `host` field to WsSettingsBean
- Replace HeadersBean with Map<String, String>? to match Xray docs
- Update CoreOutboundBuilder to use wssetting.host instead of wssetting.headers.Host

* fix: migrate wsSettings host from deprecated headers.Host to independent host field

fix: migrate wsSettings host out of deprecated headers field

- Add independent `host` field to WsSettingsBean
- Replace HeadersBean with Map<String, String>? to match Xray docs
- Update CoreOutboundBuilder to use wssetting.host instead of wssetting.headers.Host
2026-05-25 18:01:21 +08:00
2dust 0f7cee0613 Use isComplexType() to simplify config checks 2026-05-25 10:03:37 +08:00
2dust b2e10fc60b Skip complex config types when deduplicating servers
https://github.com/2dust/v2rayNG/issues/5675
2026-05-25 09:55:30 +08:00
name321467 aa3f17fb51 Add share log action (#5682)
Add a new "Share log" menu item in Logcat.The action creates a temporary .txt file from the current log output and opens the Android share sheet, allowing users to save or send logs to other apps.
2026-05-25 09:46:39 +08:00
2dust d64fc627bb up 2.2.0 2026-05-23 15:36:30 +08:00
2dust 6934ab3e5d Bump SDK to 37
Update compileSdk and targetSdk to 37. Adjust AndroidManifest: remove legacy storage read permissions, add tools:ignore for FOREGROUND_SERVICE policy and keep FOREGROUND_SERVICE_SPECIAL_USE with minSdk 34. Wrap ContextCompat.startForegroundService with SecurityException handling and special-case ForegroundServiceStartNotAllowedException (logged and rethrown as IllegalStateException) to surface permission/OS restrictions. Remove the READ_STORAGE PermissionType and related runtime permission request in HelperBaseActivity (file chooser now launches directly). These changes align permissions and service startup behavior with newer Android SDK requirements.
2026-05-23 14:56:45 +08:00
2dust 028d13aef8 Add SettingsManager.getProfileRemarks and use it 2026-05-23 11:09:46 +08:00
2dust 4a78c165bc Refactor core config, routing and balancer logic
Rework CoreConfigContextBuilder and CoreConfigManager to centralize and simplify outbound resolution and runtime config assembly. Key changes: handle CUSTOM profiles early, introduce ResolvedOutbound and resolvedOutbounds list, resolve routing-target outbounds separately, and unify normal/group/chain build flows into buildUnifiedConfig. Routing, DNS, inbounds, outbounds and balancer logic were modularized (configureInbounds/configureRouting/configureDns/configureFakeDns/configureLocalDns/etc.), balancer strategies and observability are consolidated, and domain pre-resolution/hosts handling preserved. Also added new balancer tag naming (TAG_BALANCER_PRE) and small API/utility adjustments across CoreOutboundBuilder, DTOs and enums to support the new flow. These changes improve clarity of the build pipeline, make custom/routing outbounds handling explicit, and prepare for richer policy-group balancing behavior.
2026-05-23 10:49:20 +08:00
2dust c1033a006f Use safe JSON parsing and add fromJsonSafe 2026-05-21 14:35:34 +08:00
DHR60 e8a42013c0 Remove freedom.domainStrategy (#5656) 2026-05-20 14:10:20 +08:00
vemneyy 94e5263250 Added QUIC sniffig option in destOverride to default config (#5639) 2026-05-20 14:06:56 +08:00
2dust 7c68aa9d83 Parse SOCKS userInfo without unnecessary decoding
https://github.com/2dust/v2rayNG/issues/5657
2026-05-20 13:59:54 +08:00
2dust ef13e336da up 2.1.8 2026-05-19 16:56:12 +08:00
2dust d16cb21059 Update AndroidLibXrayLite 2026-05-19 11:37:28 +08:00
2dust 237837e7be Refactor speed notification and add outbound stats
Add OutboundTrafficStat DTO and a CoreServiceManager.queryAllOutboundTrafficStats() method to fetch/reset all outbound counters in one core call and parse Go-side payload. Remove ProfileItem.getAllOutboundTags() and simplify NotificationManager API (startSpeedNotification()/stopSpeedNotification()) to use aggregated stats via the new core query. Replace per-tag polling with a single aggregated update loop (updateSpeedNotificationOnce) to reduce IPC and CPU usage, and update CoreServiceManager call sites accordingly.
2026-05-19 11:33:03 +08:00
2dust 2a6edd98be Add SOCKS4/5 and map-based parser lookup
https://github.com/2dust/v2rayNG/issues/5649
2026-05-19 09:48:49 +08:00
DHR60 9238b80675 Fix (#5651) 2026-05-18 09:18:53 +08:00
skl256 bb5e6f1959 Fix https://github.com/2dust/v2rayNG/issues/5619 (#5632)
Added "Level 0" matching to perform a full check of remarks, server, port, and password before falling back to partial matches. This ensures that identical profiles with the same remarks are correctly identified
2026-05-13 18:48:22 +08:00
2dust 1cc0865411 Add profile remark dropdowns to editor
Replace plain EditText fields for pre/next profile with AutoCompleteTextView + dropdown ImageButton in activity_sub_edit.xml and wire them up in SubEditActivity.kt. New setupProfileRemarkInputs() loads distinct non-blank server remarks from MmkvManager, sets an ArrayAdapter (threshold=0) and shows the dropdown when the field or button is clicked to make selecting existing profile remarks easier. Also adds necessary imports.
2026-05-12 11:37:47 +08:00
2dust cb8d979f23 Add dto.entities package 2026-05-12 11:25:37 +08:00
2dust 5932b0db29 Add UrlContentRequest and refactor HttpUtil
Introduce UrlContentRequest DTO to encapsulate url, timeout, httpPort, proxy credentials and userAgent. Refactor HttpUtil APIs (getUrlContent, getUrlContentWithUserAgent, downloadToFile) to accept UrlContentRequest and update callers accordingly in AngConfigManager, SpeedtestManager, UpdateCheckerManager, PerAppProxyActivity and UserAssetViewModel. This centralizes HTTP request parameters, reduces duplicated parameter lists, and preserves existing behaviors (redirect handling, userAgent fallback, proxy auth).
2026-05-12 11:05:11 +08:00
Hossein Abaspanah dbe4fe78b5 Update Luri Bakhtiari translation (#5628)
* Update Luri Bakhtiari translation

* Update strings new.xml

* Update strings.xml
2026-05-12 08:57:54 +08:00
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
2dust 659f2ded09 up 2.1.2 2026-04-24 10:52:42 +08:00
2dust 585d64b0be Bump Android Gradle Plugin to 9.1.1
Update agp from 9.1.0 to 9.1.1 in V2rayNG/gradle/libs.versions.toml to pick up the latest patch fixes and keep the build tooling current.
2026-04-24 10:52:02 +08:00
fanymagnet 2ae2e747e2 Replace package names on UIDs in custom config (#5527)
* Replace package names on UIDs in custom config

* Update V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt

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

* Apply suggestions from code review

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

* Fix copilot review & detect tun optimization

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-24 10:39:19 +08:00
2dust 4199cee4e5 Use OkHttp with proxy auth and support SOCKS creds
Replace HttpURLConnection-based networking with OkHttp and add proxy username/password support. Introduces buildOkHttpClient, updated getUrlContent/getUrlContentWithUserAgent signatures, and downloadToFile; callers in AngConfigManager, SpeedtestManager, UpdateCheckerManager, PerAppProxyActivity, UserAssetActivity and UserAssetViewModel now pass SOCKS proxy credentials. Removes createProxyConnection and related URL-resolve logic, clears HTTP inbound auth/udp in V2rayConfigManager, and simplifies SettingsManager port generation. These changes enable authenticated proxy requests, improved redirect handling and more robust file downloads.
2026-04-23 20:40:17 +08:00
dependabot[bot] 8be8a912c4 Bump robinraju/release-downloader from 1.12 to 1.13 (#5520)
Bumps [robinraju/release-downloader](https://github.com/robinraju/release-downloader) from 1.12 to 1.13.
- [Release notes](https://github.com/robinraju/release-downloader/releases)
- [Commits](https://github.com/robinraju/release-downloader/compare/v1.12...v1.13)

---
updated-dependencies:
- dependency-name: robinraju/release-downloader
  dependency-version: '1.13'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:44:50 +08:00
Kapkap5454 851f22680a Add negative UID handling to process routing (#5509)
* Handle unidentified package UID resolution

Add special handling for unidentified package names.

* Add method to create special unidentified app item

Added a method to create a special unidentified app item for the app picker.

* Support unidentified app UID and label

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-19 19:48:59 +08:00
2dust 12abc99bfc Update hev-socks5-tunnel 2026-04-19 19:31:11 +08:00
2dust 07e50637a2 Update AndroidLibXrayLite 2026-04-19 19:30:40 +08:00
2dust c1824c7598 up 2.1.1 2026-04-18 16:15:32 +08:00
2dust 18eebf999d Update AndroidLibXrayLite 2026-04-18 16:15:02 +08:00
solokot e37d6b7523 Update & improve Russian translation (#5505) 2026-04-18 16:10:49 +08:00
2dust 8c5375ec62 Add app picker and process picker integration
Introduce an AppPickerActivity to browse and select installed apps (with search, select-all and invert selection). Add AppSelectorAdapter, activity_app_picker layout and menu_app_picker menu, and register the activity in AndroidManifest.xml. Integrate the picker into RoutingEditActivity using an ActivityResult launcher and add a btn_process_picker to the routing edit layout; selected package list is loaded into the process EditText. Minor UI adjustments include padding for the EditText and marking system apps in the list.
2026-04-18 16:10:12 +08:00
2dust 30792299e8 up 2.1.0 2026-04-17 15:16:53 +08:00
solokot e6db0f6f9e Update Russian translation (#5486) 2026-04-17 15:14:36 +08:00
2dust 04478ab490 Support custom outbound tags in routing
Allow routing rules to reference outbounds by server remarks and inject missing outbounds into the generated V2ray config. Adds BUILTIN_OUTBOUND_TAGS, a pre-pass (injectCustomOutbounds) that converts referenced non-builtin tags to outbounds, and a fallback to the proxy tag when an outbound couldn't be injected. Updates getBalance to use AppConfig.TAG_PROXY constant. Replaces the outbound-tag Spinner with an AutoCompleteTextView + dropdown button that suggests builtin tags and existing profile remarks, adds a drop-down drawable and a hint string resource.
2026-04-17 15:10:19 +08:00
2dust f371da490a Kotlin formatting, import cleanup, and minor fixes 2026-04-17 14:00:38 +08:00
2dust 0a0ef6e6b5 Guard process finder init on Android Q+ 2026-04-16 13:52:35 +08:00
2dust fd57019e73 Add conditional process routing and routing strings 2026-04-16 09:56:35 +08:00
2dust 04ac6cb2d7 Update AndroidLibXrayLite 2026-04-16 09:21:54 +08:00
2dust 29a0be9629 Revert "Make tun MTU a list and port nullable"
This reverts commit f19f153f9c.
2026-04-16 09:21:15 +08:00
2dust 7aa27fd3de Add IPv6 enable preference and strings
https://github.com/2dust/v2rayNG/issues/5454
2026-04-15 19:33:55 +08:00
2dust fcc478ff63 Add launcher shortcuts to start/stop service
https://github.com/2dust/v2rayNG/issues/5485
2026-04-15 19:04:26 +08:00
2dust 54d5ad2342 Replace android.util.Log with LogUtil
Introduce a centralized LogUtil and migrate logging calls across the app to use it. Replaced android.util.Log imports/usages in many modules (fmt, handler, helper, receiver, service, ui, util, viewmodel, tests) and added a new LogUtil.kt file. Also trigger LogUtil.refreshLogLevel() when PREF_LOGLEVEL changes in MmkvPreferenceDataStore so runtime log level updates take effect.
2026-04-15 17:20:17 +08:00
2dust ba25e05536 Add process-based routing support
Introduce process field for routing rules and wire it through UI, config parsing, and runtime resolution. Adds a PackageUidResolver utility to map package names to UIDs (with caching) and converts user-entered package names into UIDs when building v2ray routing rules. Updates V2rayConfig types, RoutingEditActivity layout/bindings/strings to accept process input, and adjusts getRouting to receive Context. Also adds extra logging and a defensive check in V2RayServiceManager when resolving connection owner UID.
2026-04-15 15:46:06 +08:00
2dust 5a5a81fa0b Update hev-socks5-tunnel 2026-04-15 10:51:09 +08:00
2dust 322c4b305e Update AndroidLibXrayLite 2026-04-15 10:51:06 +08:00
2dust 590f11e671 Add Xray ProcessFinder and register it
https://github.com/2dust/AndroidLibXrayLite/commit/18c5fef49892be1c5755dd4e6ec672399521809a
https://github.com/2dust/AndroidLibXrayLite/commit/4371ac28643114042cf2499a61d381bf3d6bee75
2026-04-14 20:55:27 +08:00
2dust 872ec80292 Bug fix
https://github.com/2dust/v2rayNG/commit/d7abb8f49e3e9292c09a6fb9d2ca2f03530a7810
2026-04-14 20:49:47 +08:00
2dust f19f153f9c Make tun MTU a list and port nullable
https://github.com/XTLS/Xray-core/pull/5887
2026-04-14 14:57:12 +08:00
2dust d7abb8f49e Add dynamic local SOCKS port option
Introduce a dynamic SOCKS port feature: add PREF_DYNAMIC_SOCKS_PORT key, settings UI checkbox and localized strings, and pref XML entry. SettingsManager now holds a runtimeSocksPort, can generate and refresh a random port (generateRandomSocksPort, refreshRuntimeSocksPort) and respects the dynamic setting when returning getSocksPort(). V2Ray service startup triggers a runtime port refresh. Settings UI disables manual SOCKS port input when dynamic mode is enabled. Minor i18n additions (QR code/title) included for several locales.
2026-04-14 14:42:31 +08:00
2dust e54e713db7 Add local SOCKS username/password support 2026-04-13 20:17:30 +08:00
Copilot de91fb27db Fix VPN icon persisting in status bar after stopping service (#5476)
* Initial plan

* Fix VPN icon remaining in status bar after stopping service

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/4377b941-8f02-4d92-abc6-0f98fd6c9b60

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-13 19:35:55 +08:00
dependabot[bot] db8f46eae5 Bump android-actions/setup-android from 4.0.0 to 4.0.1 (#5456)
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v4.0.0...v4.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 19:27:05 +08:00
2dust 42e7f95e4f up 2.0.18 2026-04-03 09:19:22 +08:00
DHR60 0a52a79fa0 Support interval range (#5440) 2026-04-02 09:33:07 +08:00
DHR60 9dc1b1f2cf Support new fragment (#5437)
* Support new fragment

* Fix
2026-04-02 09:30:31 +08:00
DHR60 b61cad5a5f Support new hysteria2 stream settings (#5435)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 20:59:32 +08:00
2dust 66cd746ad2 Add server_lab_final_mask to translations 2026-04-01 10:18:59 +08:00
owo ae4a92b66b add finalmask into share link and support import manually (#5429)
* add finalmask into share link and support import manually

* remove temp test code

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

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

* Update V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt

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

* Update V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt

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

* Make finalmask Any and parse as raw string

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 10:12:50 +08:00
solokot 55c0ccf1b0 Update Russian translation (#5430) 2026-03-31 19:32:45 +08:00
2dust deedd26fac Respect append flag in custom config parsing
https://github.com/2dust/v2rayNG/pull/5424
2026-03-29 19:33:09 +08:00
owo e6c8307b96 remove old servers when update subscription (#5424)
* remove old servers when update subscription

* Supplementary Submission: Multiple Configuration Scenarios
2026-03-29 17:47:37 +08:00
2dust d6d5753283 Move locate action from FAB to menu
https://github.com/2dust/v2rayNG/issues/5423
2026-03-29 17:32:59 +08:00
2dust 014d510774 Disable SwipeRefreshLayout in GroupServerFragment
https://github.com/2dust/v2rayNG/issues/5417
2026-03-29 17:23:23 +08:00
2dust 42f89e69a7 up 2.0.17 2026-03-28 11:32:09 +08:00
2dust d5a0997775 Update AndroidLibXrayLite 2026-03-28 11:29:51 +08:00
owo af7db6f86b fix fab shadow issue (#5413) (#5416) 2026-03-27 09:22:47 +08:00
dependabot[bot] 7b7f43e12e Bump android-actions/setup-android from 3.2.0 to 4.0.0 (#5411)
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 3.2.0 to 4.0.0.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v3.2.0...v4.0.0)

---
updated-dependencies:
- dependency-name: android-actions/setup-android
  dependency-version: 4.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-03-27 09:22:38 +08:00
150 changed files with 6890 additions and 2941 deletions
+10 -10
View File
@@ -22,25 +22,25 @@ jobs:
fetch-depth: '0'
- name: Setup Android SDK
uses: android-actions/setup-android@v3.2.0
uses: android-actions/setup-android@v4
with:
log-accepted-android-sdk-licenses: false
cmdline-tools-version: '13114758'
packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
cmdline-tools-version: '14742923'
packages: 'platforms;android-37.0 build-tools;37.0.0 platform-tools'
- name: Install NDK
run: |
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
--channel=0 \
--install "ndk;28.2.13676358"
echo "NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> $GITHUB_ENV
--install "ndk;29.0.14206865"
echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
sed -i '10i\
\
ndkVersion = "28.2.13676358"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
ndkVersion = "29.0.14206865"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
- name: Restore cached libhevtun
id: cache-libhevtun-restore
uses: actions/cache/restore@v4
uses: actions/cache/restore@v5
with:
path: ${{ github.workspace }}/libs
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
@@ -52,7 +52,7 @@ jobs:
- name: Save libhevtun
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
uses: actions/cache/save@v5
with:
path: ${{ github.workspace }}/libs
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
@@ -70,7 +70,7 @@ jobs:
popd
- name: Download libv2ray
uses: robinraju/release-downloader@v1.12
uses: robinraju/release-downloader@v1.13
with:
repository: '2dust/AndroidLibXrayLite'
tag: ${{ env.CURRENT_TAG }}
@@ -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"
+1 -1
View File
@@ -3,7 +3,7 @@
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
[![API](https://img.shields.io/badge/API-24%2B-yellow.svg?style=flat)](https://developer.android.com/about/versions/lollipop)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.3.0-blue.svg)](https://kotlinlang.org)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.3.21-blue.svg)](https://kotlinlang.org)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/2dust/v2rayNG)](https://github.com/2dust/v2rayNG/commits/master)
[![CodeFactor](https://www.codefactor.io/repository/github/2dust/v2rayng/badge)](https://www.codefactor.io/repository/github/2dust/v2rayng)
[![GitHub Releases](https://img.shields.io/github/downloads/2dust/v2rayNG/latest/total?logo=github)](https://github.com/2dust/v2rayNG/releases)
+4 -4
View File
@@ -6,14 +6,14 @@ plugins {
android {
namespace = "com.v2ray.ang"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 36
versionCode = 716
versionName = "2.0.16"
targetSdk = 37
versionCode = 733
versionName = "2.2.3"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -30,4 +30,34 @@
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_start_24dp"
android:shortcutDisabledMessage="@string/toast_services_start"
android:shortcutId="shortcuts_start"
android:shortcutLongLabel="@string/toast_services_start"
android:shortcutShortLabel="@string/toast_services_start">
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScStartActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_stop_24dp"
android:shortcutDisabledMessage="@string/toast_services_stop"
android:shortcutId="shortcuts_stop"
android:shortcutLongLabel="@string/toast_services_stop"
android:shortcutShortLabel="@string/toast_services_stop">
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScStopActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>
+35 -7
View File
@@ -30,15 +30,14 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
android:minSdkVersion="34" />
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
@@ -82,12 +81,19 @@
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" />
<activity
android:name=".ui.PerAppProxyActivity"
android:exported="false" />
<activity
android:name=".ui.AppPickerActivity"
android:exported="false" />
<activity
android:name=".ui.ScannerActivity"
android:exported="false" />
@@ -122,6 +128,18 @@
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.ScStartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.ScStopActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.UrlSchemeActivity"
@@ -153,7 +171,7 @@
android:exported="false" />
<service
android:name=".service.V2RayVpnService"
android:name=".service.CoreVpnService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
@@ -173,7 +191,7 @@
</service>
<service
android:name=".service.V2RayProxyOnlyService"
android:name=".service.CoreProxyOnlyService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
@@ -184,9 +202,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"
@@ -258,6 +281,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"
@@ -30,7 +30,8 @@
"enabled": true,
"destOverride": [
"http",
"tls"
"tls",
"quic"
]
}
}
@@ -73,8 +74,10 @@
},
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIP"
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP"
}
},
"tag": "direct"
},
@@ -30,13 +30,13 @@
"enabled": true,
"destOverride": [
"http",
"tls"
"tls",
"quic"
]
}
},
{
"tag": "tun",
"port": 0,
"protocol": "tun",
"settings": {
"name": "xray0",
@@ -47,7 +47,8 @@
"enabled": true,
"destOverride": [
"http",
"tls"
"tls",
"quic"
]
}
}
@@ -90,8 +91,10 @@
},
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIP"
"streamSettings": {
"sockopt": {
"domainStrategy": "UseIP"
}
},
"tag": "direct"
},
@@ -10,8 +10,8 @@ object AppConfig {
/** Directory names used in the app's file system. */
const val DIR_ASSETS = "assets"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
/** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config"
@@ -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"
@@ -54,10 +52,15 @@ object AppConfig {
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
const val PREF_LANGUAGE = "pref_language"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
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"
@@ -74,6 +77,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"
@@ -100,7 +104,8 @@ object AppConfig {
const val TAG_FRAGMENT = "fragment"
const val TAG_DNS = "dns-module"
const val TAG_DOMESTIC_DNS = "domestic-dns"
const val TAG_BALANCER = "proxy-round"
const val TAG_BALANCER = "balancer-main"
const val TAG_BALANCER_PRE = "balancer"
/** Network-related constants. */
const val UPLINK = "uplink"
@@ -120,7 +125,8 @@ object AppConfig {
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
const val IP_API_URL = "https://api.ip.sb/geoip"
/** DNS server addresses. */
@@ -159,23 +165,23 @@ 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://"
const val CUSTOM = ""
const val SHADOWSOCKS = "ss://"
const val SOCKS = "socks://"
const val SOCKS4 = "socks4://"
const val SOCKS5 = "socks5://"
const val HTTP = "http://"
const val VLESS = "vless://"
const val TROJAN = "trojan://"
@@ -214,6 +220,8 @@ object AppConfig {
const val REALITY = "reality"
const val HEADER_TYPE_HTTP = "http"
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
@@ -274,4 +282,9 @@ object AppConfig {
"Chocolate4U/Iran-v2ray-rules"
)
val BUILTIN_OUTBOUND_TAGS = setOf(
TAG_PROXY,
TAG_DIRECT,
TAG_BLOCKED,
)
}
@@ -1,8 +1,8 @@
package com.v2ray.ang.contracts
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
interface MainAdapterListener :BaseAdapterListener {
interface MainAdapterListener : BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
@@ -11,7 +11,7 @@ interface Tun2SocksControl {
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
*/
fun startTun2Socks()
/**
* Stops the tun2socks process and cleans up resources.
*/
@@ -0,0 +1,217 @@
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.entities.ProfileItem
import com.v2ray.ang.enums.CoreResolvedType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
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
/**
* Build runtime context from the selected profile.
*
* All outbound type analysis is completed here for both the selected profile
* and routing targets. Custom profiles are returned immediately without
* entering the normal analysis flow.
*/
object CoreConfigContextBuilder {
/**
* Load one profile and produce a fully analyzed context.
*
* Null is returned only when the selected profile cannot be loaded.
*/
fun build(context: Context, guid: String): CoreConfigContext? {
val config = MmkvManager.decodeServerConfig(guid) ?: return null
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
if (config.configType == EConfigType.CUSTOM) {
return CoreConfigContext(context = context, guid = guid, isCustom = true)
}
// Step 1: Resolve the main outbound (always tag = TAG_PROXY).
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
return null
}
// Step 2: Resolve all non-builtin routing outbound tags.
val routingResolvedOutbounds = resolveRoutingOutbounds()
return CoreConfigContext(
context = context,
guid = guid,
resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds,
)
}
/**
* Resolve one outbound target into a normalized outbound entry.
*
* Custom profiles are ignored at this stage and produce no entry.
*/
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
if (profile.configType == EConfigType.CUSTOM) {
return null
}
val (resolvedProfiles, resolvedType) = when (profile.configType) {
EConfigType.POLICYGROUP -> Pair(
resolvePolicyGroupProfiles(profile),
CoreResolvedType.POLICYGROUP,
)
EConfigType.PROXYCHAIN -> {
val chainProfiles = resolveProxyChainProfiles(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
else -> {
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
}
return CoreConfigContext.ResolvedOutbound(
tag = tag,
profile = profile,
resolvedProfiles = resolvedProfiles,
resolvedType = resolvedType,
)
}
/**
* Collect and resolve non-builtin routing targets from enabled rules.
*
* Invalid or empty targets are skipped and handled by fallback logic later.
*/
private fun resolveRoutingOutbounds(): List<CoreConfigContext.ResolvedOutbound> {
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
val resolvedOutbounds = mutableListOf<CoreConfigContext.ResolvedOutbound>()
val processedTags = mutableSetOf<String>()
try {
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 {
LogUtil.w(AppConfig.TAG, "Routing tag '$tag' has no matching profile — will fall back to proxy at routing time")
return@forEach
}
val resolvedOutbound = resolveOutbound(tag, profile) ?: run {
LogUtil.w(AppConfig.TAG, "Cannot use CUSTOM profile as routing outbound for tag '$tag', skipping")
return@forEach
}
if (resolvedOutbound.resolvedProfiles.isEmpty()) {
LogUtil.w(AppConfig.TAG, "Routing outbound '$tag' resolved to empty list, skipping")
return@forEach
}
resolvedOutbounds.add(resolvedOutbound)
LogUtil.d(AppConfig.TAG, "Resolved routing outbound: tag='$tag', type='${resolvedOutbound.resolvedType}', profiles=${resolvedOutbound.resolvedProfiles.size}")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbound for tag '$tag', skipping", e)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbounds from rulesets", e)
}
return resolvedOutbounds
}
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.isComplexType() }
.toList()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
return listOf(config)
}
}
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.isComplexType() }
.toList()
.reversed()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolve chain nodes from subscription neighbors in order: next, current, prev.
*
* When no chain is available, return a single-node result.
*/
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
if (config.subscriptionId.isEmpty()) {
return listOf(config)
}
try {
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
val resolved = mutableListOf<ProfileItem>()
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 from group for '${config.remarks}'", e)
return listOf(config)
}
}
}
@@ -0,0 +1,997 @@
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.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.enums.BalancerStrategyType
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
/**
* Build the runtime configuration for normal startup.
*/
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.isCustom) {
return buildV2rayCustomConfig(configContext)
}
return toConfigResult(configContext, buildUnifiedConfig(configContext))
} 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}"
)
}
}
/**
* Build a lightweight configuration for latency testing.
*
* The core flow is reused, then non-essential sections are removed.
*/
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.isCustom) {
return buildV2rayCustomConfig(configContext)
}
val v2rayConfig = buildUnifiedConfig(configContext)
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}"
)
}
}
/**
* Build configuration for custom profiles.
*/
private fun buildV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
val context = configContext.context
val raw = MmkvManager.decodeServerRaw(configContext.guid)
?: return ConfigResult(status = false, guid = configContext.guid, errorMessage = "Custom config is empty")
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
}
/**
* Build one unified configuration for every non-custom profile type.
*
* The analyzed outbound plan is consumed in order and converted to concrete
* outbounds before routing, DNS, and runtime extras are assembled.
*/
private fun buildUnifiedConfig(configContext: CoreConfigContext): V2rayConfig {
require(configContext.resolvedOutbounds.isNotEmpty()) { "resolvedOutbounds must not be empty for a non-CUSTOM context" }
val primaryResolvedOutbound = configContext.resolvedOutbounds.first()
val v2rayConfig = initV2rayConfig(configContext)
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = primaryResolvedOutbound.profile.remarks
configureInbounds(v2rayConfig)
if (v2rayConfig.outbounds.isNotEmpty()) {
v2rayConfig.outbounds.removeAt(0)
}
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
val policyGroupBalancerTags = mutableMapOf<String, String>()
val balancerStrategies = mutableListOf<BalancerStrategy>()
// resolvedOutbounds is a single ordered plan: index 0 is primary and must be prepended,
// the rest are routing outbounds and can be appended.
configContext.resolvedOutbounds.forEachIndexed { index, spec ->
buildOutbounds(
resolvedOutbound = spec,
prepend = index == 0,
existingTags = existingTags,
v2rayConfig = v2rayConfig,
policyGroupBalancerTags = policyGroupBalancerTags,
balancerStrategies = balancerStrategies,
)
}
// User routing rules (policyGroupBalancerTags rewrites TAG_PROXY→balancer when main is POLICYGROUP).
configureRouting(configContext, v2rayConfig, policyGroupBalancerTags)
configureFakeDns(v2rayConfig)
configureDns(v2rayConfig, policyGroupBalancerTags)
configureLocalDns(v2rayConfig)
// (added by getDns / getCustomLocalDns) to use the balancer, then add
// the catch-all balancer rule.
if (primaryResolvedOutbound.resolvedType == CoreResolvedType.POLICYGROUP) {
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,
)
)
}
}
applyObservability(v2rayConfig, balancerStrategies)
applySpeedDisabled(v2rayConfig)
resolveOutboundDomainsToHosts(v2rayConfig)
return v2rayConfig
}
/**
* Convert one analyzed outbound entry into concrete outbounds and register
* them to the runtime configuration.
*/
private fun buildOutbounds(
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
prepend: Boolean,
existingTags: MutableSet<String>,
v2rayConfig: V2rayConfig,
policyGroupBalancerTags: MutableMap<String, String>,
balancerStrategies: MutableList<BalancerStrategy>,
) {
if (resolvedOutbound.tag in existingTags) {
LogUtil.w(AppConfig.TAG, "Resolved outbound tag '${resolvedOutbound.tag}' already exists, skipping duplicated entry")
return
}
when (resolvedOutbound.resolvedType) {
CoreResolvedType.NORMAL -> handleNormalResolvedOutbound(
resolvedOutbound = resolvedOutbound,
prepend = prepend,
existingTags = existingTags,
v2rayConfig = v2rayConfig,
)
CoreResolvedType.PROXYCHAIN -> handleProxyChainResolvedOutbound(
resolvedOutbound = resolvedOutbound,
prepend = prepend,
existingTags = existingTags,
v2rayConfig = v2rayConfig,
)
CoreResolvedType.POLICYGROUP -> handlePolicyGroupResolvedOutbound(
resolvedOutbound = resolvedOutbound,
prepend = prepend,
existingTags = existingTags,
v2rayConfig = v2rayConfig,
policyGroupBalancerTags = policyGroupBalancerTags,
balancerStrategies = balancerStrategies,
)
}
}
/**
* Build and insert a single-node outbound entry.
*/
private fun handleNormalResolvedOutbound(
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
prepend: Boolean,
existingTags: MutableSet<String>,
v2rayConfig: V2rayConfig,
) {
val profile = resolvedOutbound.resolvedProfiles.firstOrNull() ?: run {
LogUtil.w(AppConfig.TAG, "NORMAL resolved outbound '${resolvedOutbound.tag}' has empty resolvedProfiles, skipping")
return
}
val outbound = convertProfile2Outbound(profile) ?: run {
LogUtil.w(AppConfig.TAG, "Could not convert NORMAL resolved outbound '${resolvedOutbound.tag}' profile to outbound, skipping")
return
}
outbound.tag = resolvedOutbound.tag
if (prepend) {
v2rayConfig.outbounds.add(0, outbound)
} else {
v2rayConfig.outbounds.add(outbound)
}
existingTags.add(resolvedOutbound.tag)
}
/**
* Build and insert a multi-hop chain entry.
*/
private fun handleProxyChainResolvedOutbound(
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
prepend: Boolean,
existingTags: MutableSet<String>,
v2rayConfig: V2rayConfig,
) {
val chainOutbounds = resolvedOutbound.resolvedProfiles
.mapNotNull { convertProfile2Outbound(it) }
.toMutableList()
if (chainOutbounds.isEmpty()) {
LogUtil.w(AppConfig.TAG, "PROXYCHAIN resolved outbound '${resolvedOutbound.tag}' has no valid profiles, skipping")
return
}
if (chainOutbounds.size == 1) {
val outbound = chainOutbounds.first()
outbound.tag = resolvedOutbound.tag
if (prepend) {
v2rayConfig.outbounds.add(0, outbound)
} else {
v2rayConfig.outbounds.add(outbound)
}
existingTags.add(resolvedOutbound.tag)
return
}
val chainTags = chainOutbounds.mapIndexed { index, _ ->
if (index == 0) {
resolvedOutbound.tag
} else {
"${AppConfig.TAG_PROXY}-${resolvedOutbound.tag}-$index"
}
}
if (chainTags.any { it in existingTags }) {
LogUtil.w(
AppConfig.TAG,
"PROXYCHAIN resolved outbound '${resolvedOutbound.tag}' has colliding hop tags, skipping"
)
return
}
chainOutbounds.forEachIndexed { index, outbound ->
outbound.tag = chainTags[index]
}
for (i in 0 until chainOutbounds.size - 1) {
chainOutbounds[i].ensureSockopt().dialerProxy = chainOutbounds[i + 1].tag
}
if (prepend) {
v2rayConfig.outbounds.addAll(0, chainOutbounds)
} else {
v2rayConfig.outbounds.addAll(chainOutbounds)
}
chainOutbounds.forEach { existingTags.add(it.tag) }
}
/**
* Build and insert a policy-group entry and its balancer metadata.
*/
private fun handlePolicyGroupResolvedOutbound(
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
prepend: Boolean,
existingTags: MutableSet<String>,
v2rayConfig: V2rayConfig,
policyGroupBalancerTags: MutableMap<String, String>,
balancerStrategies: MutableList<BalancerStrategy>,
) {
val memberPairs = resolvedOutbound.resolvedProfiles.mapNotNull { profile ->
convertProfile2Outbound(profile)?.let { ob -> ob to profile }
}
if (memberPairs.isEmpty()) {
LogUtil.w(AppConfig.TAG, "POLICYGROUP resolved outbound '${resolvedOutbound.tag}' has no valid member outbounds, skipping")
return
}
val memberTagPrefix = "${AppConfig.TAG_PROXY}-${resolvedOutbound.tag}-"
val membersToAdd = mutableListOf<V2rayConfig.OutboundBean>()
memberPairs.forEachIndexed { index, (outbound, profile) ->
val memberTag = "$memberTagPrefix${index + 1}-${profile.remarks.trim()}"
if (memberTag in existingTags) {
return@forEachIndexed
}
outbound.tag = memberTag
membersToAdd.add(outbound)
existingTags.add(memberTag)
}
if (membersToAdd.isEmpty()) {
LogUtil.w(
AppConfig.TAG,
"POLICYGROUP resolved outbound '${resolvedOutbound.tag}' produced no unique member tags, skipping"
)
return
}
if (prepend) {
v2rayConfig.outbounds.addAll(0, membersToAdd)
} else {
v2rayConfig.outbounds.addAll(membersToAdd)
}
val balancerTag = if (resolvedOutbound.tag == AppConfig.TAG_PROXY) {
AppConfig.TAG_BALANCER
} else {
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
}
val strategy = buildBalancerStrategy(
policyGroupType = resolvedOutbound.profile.policyGroupType,
selector = listOf(memberTagPrefix),
balancerTag = balancerTag,
)
val existingBalancers = v2rayConfig.routing.balancers?.toMutableList() ?: mutableListOf()
if (existingBalancers.none { it.tag == balancerTag }) {
existingBalancers.add(strategy.balancer)
v2rayConfig.routing.balancers = existingBalancers
}
balancerStrategies.add(strategy)
policyGroupBalancerTags[resolvedOutbound.tag] = balancerTag
}
/**
* Trim runtime sections that are not needed for latency testing.
*/
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 }
}
/**
* Serialize a runtime configuration into a standard result object.
*/
private fun toConfigResult(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): ConfigResult {
return ConfigResult(
status = true,
guid = configContext.guid,
content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
)
}
/**
* Load the base template from cache or assets and parse it.
*/
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()
}
/**
* Configure inbound listeners and related runtime options.
*/
private fun configureInbounds(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
}
}
/**
* Enable fake DNS when local DNS and fake DNS are both enabled.
*/
private fun configureFakeDns(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
) {
v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
}
}
/**
* Collect domain rules that target one outbound tag.
*/
private fun collectUserRuleDomainsByTag(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
}
/**
* Collect domain rules that target non-builtin outbound tags.
*/
private fun collectCustomOutboundDomains(): 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
}
/**
* Configure local DNS inbounds, outbounds, and routing rules.
*/
private fun configureLocalDns(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) != true) {
return
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
val proxyDomain = collectUserRuleDomainsByTag(AppConfig.TAG_PROXY)
val directDomain = collectUserRuleDomainsByTag(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
)
)
}
}
/**
* Remove speed-test runtime sections when the feature is disabled.
*/
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
v2rayConfig.stats = null
v2rayConfig.policy = null
}
}
/**
* Configure DNS servers, hosts, and DNS routing rules.
*/
private fun configureDns(
v2rayConfig: V2rayConfig,
policyGroupBalancerTags: Map<String, String>,
) {
val hosts = mutableMapOf<String, Any>()
val servers = ArrayList<Any>()
//remote Dns
val remoteDns = SettingsManager.getRemoteDnsServers()
val proxyDomain = (collectUserRuleDomainsByTag(AppConfig.TAG_PROXY) + collectCustomOutboundDomains()).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 = collectUserRuleDomainsByTag(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 = collectUserRuleDomainsByTag(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
)
)
val dnsProxyBalancerTag = policyGroupBalancerTags[AppConfig.TAG_PROXY]
if (dnsProxyBalancerTag != null) {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
balancerTag = dnsProxyBalancerTag,
inboundTag = arrayListOf(AppConfig.TAG_DNS),
domain = null
)
)
} else {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
outboundTag = AppConfig.TAG_PROXY,
inboundTag = arrayListOf(AppConfig.TAG_DNS),
domain = null
)
)
}
}
//endregion
//region outbound related functions
/**
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
*/
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
return
}
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
}
/**
* Convert one profile object into one outbound object.
*/
private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
return CoreOutboundBuilder.convert(profileItem)
}
//endregion
//region routing related functions
/**
* Merge probe settings from all balancer strategies into the runtime config.
*/
private fun applyObservability(v2rayConfig: V2rayConfig, strategies: List<BalancerStrategy>) {
val allObsSelectors = strategies
.mapNotNull { it.observatory?.subjectSelector }
.flatten()
.distinct()
val obsTemplate = strategies.firstNotNullOfOrNull { it.observatory }
if (obsTemplate != null && allObsSelectors.isNotEmpty()) {
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
subjectSelector = allObsSelectors,
probeUrl = obsTemplate.probeUrl,
probeInterval = obsTemplate.probeInterval,
enableConcurrency = obsTemplate.enableConcurrency
)
}
val allBurstSelectors = strategies
.mapNotNull { it.burstObservatory?.subjectSelector }
.flatten()
.distinct()
val burstTemplate = strategies.firstNotNullOfOrNull { it.burstObservatory }
if (burstTemplate != null && allBurstSelectors.isNotEmpty()) {
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
subjectSelector = allBurstSelectors,
pingConfig = burstTemplate.pingConfig
)
}
}
/**
* Configure routing domain strategy and append enabled user rules.
*/
private fun configureRouting(
configContext: CoreConfigContext,
v2rayConfig: V2rayConfig,
policyGroupBalancerTags: Map<String, String>
) {
v2rayConfig.routing.domainStrategy =
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
?: "AsIs"
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
appendRoutingUserRule(configContext, key, v2rayConfig, policyGroupBalancerTags)
}
}
/**
* Convert one rule item and append it to routing rules.
*/
private fun appendRoutingUserRule(
configContext: CoreConfigContext,
item: RulesetItem?,
v2rayConfig: V2rayConfig,
policyGroupBalancerTags: Map<String, String>
) {
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
}
val outboundTag = rule.outboundTag
// Route rules targeting a custom policy-group tag should hit its balancer.
policyGroupBalancerTags[outboundTag]?.let { balancerTag ->
rule.outboundTag = null
rule.balancerTag = balancerTag
}
// If the outbound tag is a custom one that failed to inject, fall back to proxy
if (!outboundTag.isNullOrBlank()
&& outboundTag !in policyGroupBalancerTags
&& 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)
}
/**
* Build balancer and probe settings from one policy-group strategy value.
*/
private fun buildBalancerStrategy(
policyGroupType: String?,
selector: List<String>,
balancerTag: String = AppConfig.TAG_BALANCER,
): BalancerStrategy {
val probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL
val strategyType = BalancerStrategyType.from(policyGroupType)
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = balancerTag,
selector = selector,
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
)
val observatory = if (strategyType.requiresObservatory) {
V2rayConfig.ObservatoryObject(
subjectSelector = selector,
probeUrl = probeUrl,
probeInterval = "3m",
enableConcurrency = true
)
} else null
val burstObservatory = if (strategyType.requiresBurstObservatory) {
V2rayConfig.BurstObservatoryObject(
subjectSelector = selector,
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
destination = probeUrl,
interval = "5m",
sampling = 2,
timeout = "30s"
)
)
} else null
return BalancerStrategy(balancer, observatory, burstObservatory)
}
/**
* Carry balancer data plus optional probe settings for later merge.
*/
private data class BalancerStrategy(
val balancer: V2rayConfig.RoutingBean.BalancerBean,
val observatory: V2rayConfig.ObservatoryObject? = null,
val burstObservatory: V2rayConfig.BurstObservatoryObject? = null,
)
//endregion
}
@@ -1,8 +1,8 @@
package com.v2ray.ang.handler
package com.v2ray.ang.core
import android.content.Context
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import go.Seq
import libv2ray.CoreCallbackHandler
@@ -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)
/**
@@ -32,14 +32,23 @@ object V2RayNativeManager {
val assetPath = Utils.userAssetPath(context)
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
Libv2ray.initCoreEnv(assetPath, deviceId)
Log.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
LogUtil.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
LogUtil.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
initialized.set(false)
throw e
}
} else {
Log.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
}
}
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)
}
}
@@ -53,7 +62,7 @@ object V2RayNativeManager {
return try {
Libv2ray.checkVersionX()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check V2Ray version", e)
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
"Unknown"
}
}
@@ -69,7 +78,7 @@ object V2RayNativeManager {
return try {
Libv2ray.measureOutboundDelay(config, testUrl)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
-1L
}
}
@@ -84,8 +93,8 @@ object V2RayNativeManager {
return try {
Libv2ray.newCoreController(handler)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to create core controller", e)
LogUtil.e(AppConfig.TAG, "Failed to create core controller", e)
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.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
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.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 && profileItem.pinnedCA256.isNullOrEmpty()
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,21 +1,34 @@
package com.v2ray.ang.handler
package com.v2ray.ang.core
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import android.system.OsConstants
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.OutboundTrafficStat
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.service.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.extension.toastError
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
import kotlinx.coroutines.CoroutineScope
@@ -23,18 +36,27 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
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
V2RayNativeManager.initCoreEnv(value?.get()?.getService())
val service = value?.get()?.getService()
CoreNativeManager.initCoreEnv(service)
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
processFinder = XrayProcessFinder(service)
coreController.registerProcessFinder(processFinder)
}
}
/**
@@ -47,7 +69,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
}
@@ -57,13 +85,18 @@ object V2RayServiceManager {
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) {
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)
}
}
/**
@@ -91,35 +124,47 @@ 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) {
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
Log.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) {
Log.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
if (!config.configType.isComplexType()
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) {
Log.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
return
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
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 (config.insecure == true) {
context.toastError(R.string.toast_allow_insecure_deprecated)
context.toastError(R.string.toast_allow_insecure_deprecated)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
context.toast(R.string.toast_warning_pref_proxysharing_short)
@@ -129,17 +174,26 @@ object V2RayServiceManager {
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
Log.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, V2RayVpnService::class.java)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, CoreVpnService::class.java)
} else {
Log.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
} catch (e: SecurityException) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Missing permission to start foreground service", e)
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
} catch (e: RuntimeException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Foreground service start not allowed", e)
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
}
throw e
}
}
@@ -150,76 +204,80 @@ object V2RayServiceManager {
*/
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
if (coreController.isRunning) {
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false
}
val service = getService()
if (service == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: Service is null")
return false
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return false
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return false
}
Log.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
if (!result.status) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
return false
}
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())
doStartCoreLoop(service, vpnInterface)
return true
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
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
}
}
@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 = CoreConfigManager.getV2rayConfig(service, guid)
LogUtil.d(AppConfig.TAG, result.content)
if (!result.status) {
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
}
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) {
Log.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) {
Log.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)
Log.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
return false
}
return true
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.startSpeedNotification()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
}
/**
@@ -235,31 +293,57 @@ object V2RayServiceManager {
try {
coreController.stopLoop()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
}
}
}
// Close existing browser dialer
CoreNativeManager.reconcileBrowserDialer("")
if (browserDialer != null) {
browserDialer!!.stop()
browserDialer = null
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
NotificationManager.cancelNotification()
try {
service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
}
return true
}
/**
* Queries the statistics for a given tag and link.
* @param tag The tag to query.
* @param link The link to query.
* @return The statistics value.
* Queries and resets all outbound traffic counters in one core call.
* Go side format: tag,direction,value;tag,direction,value;
*/
fun queryStats(tag: String, link: String): Long {
return coreController.queryStats(tag, link)
fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> {
val payload = coreController.queryAllOutboundTrafficStats()
val result = ArrayList<OutboundTrafficStat>()
payload.split(';').forEach { entry ->
if (entry.isBlank()) return@forEach
val parts = entry.split(',', limit = 3)
if (parts.size != 3) return@forEach
val value = parts[2].toLongOrNull() ?: return@forEach
result.add(
OutboundTrafficStat(
tag = parts[0],
direction = parts[1],
value = value,
)
)
}
// LogUtil.d(AppConfig.TAG, "Queried outbound traffic stats: $result")
return result
}
/**
@@ -280,14 +364,14 @@ object V2RayServiceManager {
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
if (time == -1L) {
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
}
@@ -339,7 +423,7 @@ object V2RayServiceManager {
serviceControl.stopService()
0
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
-1
}
}
@@ -355,6 +439,43 @@ object V2RayServiceManager {
}
}
/**
* Process finder implementation for Xray core.
* Uses ConnectivityManager to find the owning UID of a connection based on network parameters.
*/
private class XrayProcessFinder(context: Context) : ProcessFinder {
private val cm: ConnectivityManager? = context.getSystemService(ConnectivityManager::class.java)
override fun findProcessByConnection(network: String, srcIP: String, srcPort: Long, destIP: String, destPort: Long): Long {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return -1L
if (cm == null) return -1L
val proto = when (network) {
"tcp" -> OsConstants.IPPROTO_TCP
"udp" -> OsConstants.IPPROTO_UDP
else -> return -1L
}
if (destIP.isBlank() || destPort == 0L) {
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to :$destPort, (no dest)")
return -1L
}
return try {
val uid = cm.getConnectionOwnerUid(
proto,
InetSocketAddress(srcIP, srcPort.toInt()),
InetSocketAddress(destIP, destPort.toInt())
).toLong()
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid")
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
uid
} catch (_: Exception) {
-1L
}
}
}
/**
* Broadcast receiver for handling messages sent to the service.
* Handles registration, service control, and screen events.
@@ -386,12 +507,12 @@ object V2RayServiceManager {
}
AppConfig.MSG_STATE_STOP -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
serviceControl.stopService()
}
AppConfig.MSG_STATE_RESTART -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Restart service")
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService()
Thread.sleep(500L)
startVService(serviceControl.getService())
@@ -404,13 +525,13 @@ object V2RayServiceManager {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification(currentConfig)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification()
}
Intent.ACTION_SCREEN_ON -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification(currentConfig)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification()
}
}
}
@@ -4,5 +4,6 @@ data class ConfigResult(
var status: Boolean,
var guid: String? = null,
var content: String = "",
var errorMessage: String = "",
)
@@ -0,0 +1,19 @@
package com.v2ray.ang.dto
import android.content.Context
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.CoreResolvedType
data class CoreConfigContext(
val context: Context,
val guid: String,
val isCustom: Boolean = false,
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
) {
data class ResolvedOutbound(
val tag: String,
val profile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
)
}
@@ -0,0 +1,7 @@
package com.v2ray.ang.dto
data class OutboundTrafficStat(
val tag: String,
val direction: String,
val value: Long,
)
@@ -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()
}
@@ -0,0 +1,10 @@
package com.v2ray.ang.dto
data class UrlContentRequest(
val url: String?,
val timeout: Int = 15000,
val httpPort: Int = 0,
val proxyUsername: String? = null,
val proxyPassword: String? = null,
val userAgent: String? = null
)
@@ -31,7 +31,7 @@ data class V2rayConfig(
data class InboundBean(
var tag: String,
var port: Int,
var port: Int?,
var protocol: String,
var listen: String? = null,
var settings: InSettingsBean? = null,
@@ -44,10 +44,16 @@ data class V2rayConfig(
var auth: String? = null,
var udp: Boolean? = null,
var userLevel: Int? = null,
var accounts: List<SocksAccountBean>? = null,
var name: String? = null,
@SerializedName("MTU")
var mtu: Int? = null
)
) {
data class SocksAccountBean(
var user: String = "",
var pass: String = ""
)
}
data class SniffingBean(
var enabled: Boolean,
@@ -62,14 +68,11 @@ data class V2rayConfig(
var protocol: String,
var settings: OutSettingsBean? = null,
var streamSettings: StreamSettingsBean? = null,
val proxySettings: Any? = null,
val sendThrough: String? = null,
var mux: MuxBean? = MuxBean(false)
) {
data class OutSettingsBean(
var vnext: List<VnextBean>? = null,
var fragment: FragmentBean? = null,
var noises: List<NoiseBean>? = null,
var servers: List<ServersBean>? = null,
/*Blackhole*/
var response: Response? = null,
@@ -78,7 +81,6 @@ data class V2rayConfig(
var address: Any? = null,
var port: Int? = null,
/*Freedom*/
var domainStrategy: String? = null,
val redirect: String? = null,
val userLevel: Int? = null,
/*Loopback*/
@@ -108,18 +110,6 @@ data class V2rayConfig(
)
}
data class FragmentBean(
var packets: String? = null,
var length: String? = null,
var interval: String? = null
)
data class NoiseBean(
var type: String? = null,
var packet: String? = null,
var delay: String? = null
)
data class ServersBean(
var address: String = "",
var method: String? = null,
@@ -162,7 +152,7 @@ data class V2rayConfig(
var realitySettings: TlsSettingsBean? = null,
var grpcSettings: GrpcSettingsBean? = null,
var hysteriaSettings: HysteriaSettingsBean? = null,
var finalmask: FinalMaskBean? = null,
var finalmask: Any? = null,
val dsSettings: Any? = null,
var sockopt: SockoptBean? = null
) {
@@ -189,7 +179,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
)
}
}
@@ -207,13 +197,12 @@ data class V2rayConfig(
data class WsSettingsBean(
var path: String? = null,
var headers: HeadersBean = HeadersBean(),
var host: String? = null,
var headers: Map<String, String>? = null,
val maxEarlyData: Int? = null,
val useBrowserForwarding: Boolean? = null,
val acceptProxyProtocol: Boolean? = null
) {
data class HeadersBean(var Host: String = "")
}
)
data class HttpupgradeSettingsBean(
var path: String? = null,
@@ -264,7 +253,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,
@@ -292,28 +280,51 @@ data class V2rayConfig(
data class HysteriaSettingsBean(
var version: Int,
var auth: String? = null,
var up: String? = null,
var down: String? = null,
var udphop: HysteriaUdpHopBean? = null
) {
data class HysteriaUdpHopBean(
var port: String? = null,
var interval: Int? = null
)
}
var auth: String? = null
)
//https://xtls.github.io/config/transport.html#finalmaskobject
data class FinalMaskBean(
var tcp: List<MaskBean>? = null,
var udp: List<MaskBean>? = null
var udp: List<MaskBean>? = null,
var quicParams: QuicParamsBean? = null
) {
data class MaskBean(
var type: String,
var settings: MaskSettingsBean? = null
) {
data class MaskSettingsBean(
var password: String? = null,
var domain: String? = null
val password: String? = null,
val domain: String? = null,
// fragment
val packets: String? = null,
val length: String? = null,
val delay: String? = null,
// val maxSplit: String? = null,
// noise
val reset: Int? = null,
val noise: List<NoiseMaskBean>? = null
) {
data class NoiseMaskBean(
val rand: String? = null,
// val randRange: String? = null,
// val type: String? = null,
// val packet: String? = null,
val delay: String? = null,
)
}
}
data class QuicParamsBean(
var congestion: String? = null,
var brutalUp: String? = null,
var brutalDown: String? = null,
var udpHop: UdpHopBean? = null,
) {
// Nested data class for the udpHop JSON object
data class UdpHopBean(
var ports: String? = null,
var interval: String? = null
)
}
}
@@ -330,19 +341,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
}
@@ -351,15 +362,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)
) {
@@ -387,6 +398,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(
@@ -409,8 +421,9 @@ data class V2rayConfig(
data class RulesBean(
var type: String = "field",
var ip: ArrayList<String>? = null,
var domain: ArrayList<String>? = null,
var ip: List<String>? = null,
var domain: List<String>? = null,
var process: List<String>? = null,
var outboundTag: String? = null,
var balancerTag: String? = null,
var port: String? = null,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class AssetUrlCache(
val guid: String,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class AssetUrlItem(
var remarks: String = "",
@@ -1,10 +1,6 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.AppConfig.PORT_SOCKS
import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.AppConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils
@@ -29,6 +25,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,
@@ -36,14 +35,13 @@ data class ProfileItem(
var authority: String? = null,
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null,
var sni: String? = null,
var alpn: String? = null,
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 +65,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 {
@@ -75,13 +76,9 @@ data class ProfileItem(
}
}
fun getAllOutboundTags(): MutableList<String> {
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
}
fun getServerAddressAndPort(): String {
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
return "$LOOPBACK:$PORT_SOCKS"
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
}
return Utils.getIpv6Address(server) + ":" + serverPort
}
@@ -102,6 +99,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 +124,7 @@ data class ProfileItem(
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinnedCA256 == obj.pinnedCA256
&& this.proxyChainProfiles == obj.proxyChainProfiles
)
}
}
@@ -1,9 +1,10 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class RulesetItem(
var remarks: String? = "",
var ip: List<String>? = null,
var domain: List<String>? = null,
var process: List<String>? = null,
var outboundTag: String = "",
var port: String? = null,
var network: String? = null,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
fun getTestDelayString(): String {
@@ -7,4 +7,4 @@ data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
}
return testDelayMillis.toString() + "ms"
}
}
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class ServersCache(
val guid: String,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class SubscriptionCache(
val guid: String,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class SubscriptionItem(
var remarks: String = "",
@@ -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,
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.dto.entities
data class WebDavConfig(
val baseUrl: String,
@@ -6,4 +6,4 @@ data class WebDavConfig(
val password: String? = null,
val remoteBasePath: String = "/",
val timeoutSeconds: Long = 30
)
)
@@ -0,0 +1,26 @@
package com.v2ray.ang.enums
/**
* Load-balancing strategy types supported by xray balancer.
*
* @param policyGroupType The string value written into the xray config JSON.
* @param policyGroupTypeValue The numeric string stored in [com.v2ray.ang.dto.entities.ProfileItem.policyGroupType].
* @param requiresBurstObservatory Whether this strategy needs a burstObservatory (leastLoad).
* @param requiresObservatory Whether this strategy needs an observatory (leastPing).
*/
enum class BalancerStrategyType(
val policyGroupType: String,
val policyGroupTypeValue: String,
val requiresBurstObservatory: Boolean = false,
val requiresObservatory: Boolean = false,
) {
LEAST_LOAD("leastLoad", "1", requiresBurstObservatory = true),
RANDOM("random", "2"),
ROUND_ROBIN("roundRobin", "3"),
LEAST_PING("leastPing", "", requiresObservatory = true); // default / else
companion object {
fun from(policyGroupType: String?): BalancerStrategyType =
entries.firstOrNull { it.policyGroupTypeValue == policyGroupType } ?: LEAST_PING
}
}
@@ -0,0 +1,8 @@
package com.v2ray.ang.enums
/** Runtime type used during config assembly only. */
enum class CoreResolvedType {
NORMAL,
POLICYGROUP,
PROXYCHAIN,
}
@@ -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
)
}
@@ -13,17 +13,6 @@ enum class PermissionType {
override fun getPermission(): String = Manifest.permission.CAMERA
},
/** Read storage / media permission (adapts to Android version) */
READ_STORAGE {
override fun getPermission(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
},
/** Notification permission (Android 13+) */
POST_NOTIFICATIONS {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@@ -37,7 +26,6 @@ enum class PermissionType {
fun getLabel(): String {
return when (this) {
CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification"
}
}
@@ -8,6 +8,7 @@ import android.os.Build
import android.os.Bundle
import android.widget.Toast
import com.v2ray.ang.AngApplication
import com.v2ray.ang.enums.EConfigType
import es.dmoral.toasty.Toasty
import java.io.Serializable
import java.net.URI
@@ -200,4 +201,22 @@ fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean =
}
return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase)
}
}
/**
* Checks if the config type is a group type (PolicyGroup or ProxyChain).
*
* @return True if the config type is PolicyGroup or ProxyChain, false otherwise.
*/
fun EConfigType.isGroupType(): Boolean {
return this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
/**
* Checks if the config type is a complex type (Custom, PolicyGroup, or ProxyChain).
*
* @return True if the config type is Custom, PolicyGroup, or ProxyChain, false otherwise.
*/
fun EConfigType.isComplexType(): Boolean {
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.JsonUtil
@@ -12,7 +12,7 @@ object CustomFmt : FmtBase() {
* @param str the JSON string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
fun parse(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.CUSTOM)
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
@@ -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
}
@@ -1,10 +1,9 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.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
@@ -51,15 +50,16 @@ open class FmtBase {
*
* @param config the ProfileItem object to populate
* @param queryParam the query parameters to use for populating the ProfileItem
* @param allowInsecure whether to allow insecure connections
*/
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>, allowInsecure: Boolean) {
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>) {
config.network = queryParam["type"] ?: NetworkType.TCP.type
config.headerType = queryParam["headerType"]
config.host = queryParam["host"]
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"]
@@ -67,6 +67,7 @@ open class FmtBase {
config.authority = queryParam["authority"]
config.xhttpMode = queryParam["mode"]
config.xhttpExtra = queryParam["extra"]
config.finalMask = queryParam["fm"]
config.security = queryParam["security"]
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
@@ -77,7 +78,7 @@ open class FmtBase {
config.insecure = when {
allowInsecureKeys.any { queryParam[it] == "1" } -> true
allowInsecureKeys.any { queryParam[it] == "0" } -> false
else -> allowInsecure
else -> false
}
config.sni = queryParam["sni"]
config.fingerPrint = queryParam["fp"]
@@ -110,6 +111,9 @@ open class FmtBase {
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
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"
@@ -166,21 +170,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
}
}
@@ -1,16 +1,13 @@
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.dto.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
import com.v2ray.ang.dto.entities.ProfileItem
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
@@ -21,8 +18,7 @@ object Hysteria2Fmt : FmtBase() {
* @param str the Hysteria2 URI string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
fun parse(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -36,7 +32,7 @@ object Hysteria2Fmt : FmtBase() {
if (!uri.rawQuery.isNullOrEmpty()) {
val queryParam = getQueryParam(uri)
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
config.security = queryParam["security"] ?: AppConfig.TLS
config.obfsPassword = queryParam["obfs-password"]
@@ -73,7 +69,37 @@ object Hysteria2Fmt : FmtBase() {
dicQuery["mport"] = config.portHopping.orEmpty()
}
if (config.portHoppingInterval.isNotNullEmpty()) {
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
val rawInterval = config.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
null
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
null
} 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) / 2
} else {
null
}
} else {
null
}
}
}
if (interval != null) {
dicQuery["mportHopInt"] = interval.toString()
}
}
if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
@@ -81,44 +107,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)
}
if (profileItem.obfsPassword.isNotNullEmpty()) {
outboundBean.streamSettings?.finalmask = FinalMaskBean(
udp = listOf(
FinalMaskBean.MaskBean(
type = "salamander",
settings = FinalMaskBean.MaskBean.MaskSettingsBean(
password = profileItem.obfsPassword
)
)
)
)
}
return outboundBean
}
}
@@ -1,13 +1,11 @@
package com.v2ray.ang.fmt
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
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
@@ -85,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
config.remarks =
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
LogUtil.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
}
result = result.substring(0, indexSplit)
@@ -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.dto.entities.ProfileItem
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
@@ -28,7 +26,11 @@ object SocksFmt : FmtBase() {
config.serverPort = uri.port.toString()
if (uri.userInfo?.isEmpty() == false) {
val result = Utils.decode(uri.userInfo).split(":", limit = 2)
val result = if (uri.userInfo.contains(":")) {
uri.userInfo.split(":", limit = 2)
} else {
Utils.decode(uri.userInfo).split(":", limit = 2)
}
if (result.count() == 2) {
config.username = result.first()
config.password = result.last()
@@ -53,27 +55,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
}
}
@@ -1,13 +1,11 @@
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.dto.entities.ProfileItem
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
@@ -18,8 +16,7 @@ object TrojanFmt : FmtBase() {
* @param str the Trojan URI string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
fun parse(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -31,11 +28,11 @@ object TrojanFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) {
config.network = NetworkType.TCP.type
config.security = AppConfig.TLS
config.insecure = allowInsecure
config.insecure = false
} else {
val queryParam = getQueryParam(uri)
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
config.security = queryParam["security"] ?: AppConfig.TLS
}
@@ -53,31 +50,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
}
}
@@ -1,12 +1,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.dto.entities.ProfileItem
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
@@ -19,7 +17,6 @@ object VlessFmt : FmtBase() {
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VLESS)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -32,7 +29,7 @@ object VlessFmt : FmtBase() {
config.password = uri.userInfo
config.method = queryParam["encryption"] ?: "none"
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
return config
}
@@ -50,31 +47,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
}
}
@@ -1,18 +1,16 @@
package com.v2ray.ang.fmt
import android.text.TextUtils
import android.util.Log
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.dto.entities.ProfileItem
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
import java.net.URI
@@ -28,13 +26,12 @@ object VmessFmt : FmtBase() {
return parseVmessStd(str)
}
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VMESS)
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
result = Utils.decode(result)
if (TextUtils.isEmpty(result)) {
Log.w(AppConfig.TAG, "Toast decoding failed")
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
return null
}
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
@@ -44,7 +41,7 @@ object VmessFmt : FmtBase() {
|| TextUtils.isEmpty(vmessQRCode.id)
|| TextUtils.isEmpty(vmessQRCode.net)
) {
Log.w(AppConfig.TAG, "Toast incorrect protocol")
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
return null
}
@@ -89,7 +86,7 @@ object VmessFmt : FmtBase() {
config.insecure = when (vmessQRCode.insecure) {
"1" -> true
"0" -> false
else -> allowInsecure
else -> false
}
return config
}
@@ -156,7 +153,6 @@ object VmessFmt : FmtBase() {
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parseVmessStd(str: String): ProfileItem? {
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VMESS)
val uri = URI(Utils.fixIllegalUrl(str))
@@ -169,36 +165,10 @@ object VmessFmt : FmtBase() {
config.password = uri.userInfo
config.method = AppConfig.DEFAULT_SECURITY
getItemFormQuery(config, queryParam, allowInsecure)
getItemFormQuery(config, queryParam)
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.dto.entities.ProfileItem
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)
@@ -46,7 +43,7 @@ object WireguardFmt : FmtBase() {
* @param str the Wireguard configuration file string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parseWireguardConfFile(str: String): ProfileItem? {
fun parseWireguardConfFile(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.WIREGUARD)
val interfaceParams: MutableMap<String, String> = mutableMapOf()
@@ -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.
@@ -3,14 +3,14 @@ package com.v2ray.ang.handler
import android.content.Context
import android.graphics.Bitmap
import android.text.TextUtils
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt
@@ -23,12 +23,28 @@ import com.v2ray.ang.fmt.VmessFmt
import com.v2ray.ang.fmt.WireguardFmt
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import java.net.URI
object AngConfigManager {
// Parser mapping for different config types (lazy initialized)
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
mapOf(
EConfigType.VMESS.protocolScheme to VmessFmt::parse,
EConfigType.SHADOWSOCKS.protocolScheme to ShadowsocksFmt::parse,
EConfigType.SOCKS.protocolScheme to SocksFmt::parse,
AppConfig.SOCKS4 to SocksFmt::parse,
AppConfig.SOCKS5 to SocksFmt::parse,
EConfigType.TROJAN.protocolScheme to TrojanFmt::parse,
EConfigType.VLESS.protocolScheme to VlessFmt::parse,
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
AppConfig.HY2 to Hysteria2Fmt::parse
)
}
/**
* Shares the configuration to the clipboard.
@@ -47,7 +63,7 @@ object AngConfigManager {
Utils.setClipboard(context, conf)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share config to clipboard", e)
return -1
}
return 0
@@ -76,7 +92,7 @@ object AngConfigManager {
}
return sb.lines().count() - 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
return -1
}
}
@@ -96,7 +112,7 @@ object AngConfigManager {
return QRCodeDecoder.createQRCode(conf)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config as QR code", e)
LogUtil.e(AppConfig.TAG, "Failed to share config as QR code", e)
return null
}
}
@@ -111,14 +127,14 @@ 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 {
return -1
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
return -1
}
return 0
@@ -136,19 +152,16 @@ 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) {
Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
LogUtil.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
return ""
}
}
@@ -167,7 +180,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid)
count = parseCustomConfigServer(server, subid, append)
}
var countSub = parseBatchSubscription(server)
@@ -203,7 +216,7 @@ object AngConfigManager {
}
return count
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch subscription", e)
LogUtil.e(AppConfig.TAG, "Failed to parse batch subscription", e)
}
return 0
}
@@ -221,15 +234,8 @@ object AngConfigManager {
if (servers == null) {
return 0
}
// Find the currently selected server that matches the subscription ID
val removedSelected = if (subid.isNotBlank() && !append) {
MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
} else {
null
}
// Find the currently selected server that belongs to the same subscription before replacement.
val removedSelected = getRemovedSelectedProfile(subid, append)
val subItem = MmkvManager.decodeSubscription(subid)
@@ -257,7 +263,7 @@ object AngConfigManager {
return configs.size
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
}
return 0
}
@@ -275,7 +281,6 @@ object AngConfigManager {
// Read serverList once
val serverList = MmkvManager.decodeServerList(subid)
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
configs.forEach { config ->
val key = Utils.getUuid()
@@ -284,10 +289,6 @@ object AngConfigManager {
if (!serverList.contains(key)) {
serverList.add(0, key)
if (needSetSelected) {
MmkvManager.setSelectServer(key)
needSetSelected = false
}
}
keyToProfile[key] = config
}
@@ -310,7 +311,18 @@ object AngConfigManager {
* @return Matched key or null
*/
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty() || target == null) return null
if (keyToProfile.isEmpty()) return null
if (target == null) return null
// Level 0: Full match (remarks + server + port + password)
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks) &&
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
}
// Level 1: Match by remarks
if (target.remarks.isNotBlank()) {
@@ -337,7 +349,20 @@ object AngConfigManager {
isSameText(saved.server, target.server)
}?.key?.let { return it }
return null
// If old selected node cannot be matched, fall back to the first imported config.
return keyToProfile.keys.firstOrNull()
}
/**
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
*/
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
if (subid.isBlank() || append) return null
return MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
}
/**
@@ -357,9 +382,10 @@ object AngConfigManager {
*
* @param server The server string.
* @param subid The subscription ID.
* @param append Whether to append the configurations.
* @return The number of configurations parsed.
*/
private fun parseCustomConfigServer(server: String?, subid: String): Int {
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
if (server == null) {
return 0
}
@@ -372,19 +398,29 @@ object AngConfigManager {
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) {
val removedSelected = getRemovedSelectedProfile(subid, append)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
val keyToProfile = mutableMapOf<String, ProfileItem>()
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
keyToProfile[key] = config
count += 1
}
if (count > 0) {
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
return count
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
}
try {
@@ -392,22 +428,28 @@ object AngConfigManager {
val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
return 0
} else {
@@ -434,22 +476,8 @@ object AngConfigManager {
return null
}
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
VmessFmt.parse(str)
} else if (str.startsWith(EConfigType.SHADOWSOCKS.protocolScheme)) {
ShadowsocksFmt.parse(str)
} else if (str.startsWith(EConfigType.SOCKS.protocolScheme)) {
SocksFmt.parse(str)
} else if (str.startsWith(EConfigType.TROJAN.protocolScheme)) {
TrojanFmt.parse(str)
} else if (str.startsWith(EConfigType.VLESS.protocolScheme)) {
VlessFmt.parse(str)
} else if (str.startsWith(EConfigType.WIREGUARD.protocolScheme)) {
WireguardFmt.parse(str)
} else if (str.startsWith(EConfigType.HYSTERIA2.protocolScheme) || str.startsWith(HY2)) {
Hysteria2Fmt.parse(str)
} else {
null
val config = configFmtParsers.firstNotNullOfOrNull { (scheme, parser) ->
if (str.startsWith(scheme)) parser(str) else null
}
if (config == null) {
@@ -468,7 +496,7 @@ object AngConfigManager {
return config
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse config", e)
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
return null
}
}
@@ -485,7 +513,7 @@ object AngConfigManager {
acc + updateConfigViaSub(subscription)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
LogUtil.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
SubscriptionUpdateResult()
}
}
@@ -520,21 +548,37 @@ object AngConfigManager {
return SubscriptionUpdateResult(failureCount = 1)
}
}
Log.i(AppConfig.TAG, url)
LogUtil.i(AppConfig.TAG, url)
val userAgent = it.subscription.userAgent
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent,
timeout = 15000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
)
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
HttpUtil.getUrlContentWithUserAgent(
UrlContentRequest(
url = url,
userAgent = userAgent
)
)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
}
}
@@ -546,7 +590,7 @@ object AngConfigManager {
if (count > 0) {
it.subscription.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(it.guid, it.subscription)
Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
return SubscriptionUpdateResult(
configCount = count,
successCount = 1
@@ -556,7 +600,7 @@ object AngConfigManager {
return SubscriptionUpdateResult(failureCount = 1)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
LogUtil.e(AppConfig.TAG, "Failed to update config via subscription", e)
return SubscriptionUpdateResult(failureCount = 1)
}
}
@@ -575,7 +619,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid)
count = parseCustomConfigServer(server, subid, append)
}
return count
}
@@ -4,14 +4,14 @@ import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlCache
import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServerAffiliationInfo
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
@@ -102,7 +102,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
@@ -144,7 +144,7 @@ object MmkvManager {
if (json.isNullOrBlank()) {
return null
}
return JsonUtil.fromJson(json, ProfileItem::class.java)
return JsonUtil.fromJsonSafe(json, ProfileItem::class.java)
}
@@ -247,7 +247,7 @@ object MmkvManager {
if (json.isNullOrBlank()) {
return null
}
return JsonUtil.fromJson(json, ServerAffiliationInfo::class.java)
return JsonUtil.fromJsonSafe(json, ServerAffiliationInfo::class.java)
}
/**
@@ -344,7 +344,7 @@ object MmkvManager {
//region Subscriptions
private fun getSubscriptionId(subscriptionId: String?):String {
private fun getSubscriptionId(subscriptionId: String?): String {
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
}
@@ -374,7 +374,7 @@ object MmkvManager {
decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
val item = JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java) ?: SubscriptionItem()
subscriptions.add(SubscriptionCache(key, item))
}
}
@@ -420,7 +420,7 @@ object MmkvManager {
*/
fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
val json = subStorage.decodeString(subscriptionId) ?: return null
return JsonUtil.fromJson(json, SubscriptionItem::class.java)
return JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java)
}
/**
@@ -442,7 +442,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList()?: mutableListOf()
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
@@ -460,7 +460,7 @@ object MmkvManager {
assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
val item = JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java) ?: AssetUrlItem()
assetUrlItems.add(AssetUrlCache(key, item))
}
}
@@ -495,7 +495,7 @@ object MmkvManager {
*/
fun decodeAsset(assetid: String): AssetUrlItem? {
val json = assetStorage.decodeString(assetid) ?: return null
return JsonUtil.fromJson(json, AssetUrlItem::class.java)
return JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java)
}
//endregion
@@ -510,7 +510,7 @@ object MmkvManager {
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
if (ruleset.isNullOrEmpty()) return null
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList()?: mutableListOf()
return JsonUtil.fromJsonSafe(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
}
/**
@@ -679,6 +679,7 @@ object MmkvManager {
return settingsStorage.decodeStringSet(key)
}
/**
* Encodes the start on boot setting.
*
@@ -713,7 +714,7 @@ object MmkvManager {
*/
fun decodeWebDavConfig(): WebDavConfig? {
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
return JsonUtil.fromJson(json, WebDavConfig::class.java)
return JsonUtil.fromJsonSafe(json, WebDavConfig::class.java)
}
//endregion
@@ -9,14 +9,15 @@ import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.ui.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -42,53 +43,15 @@ object NotificationManager {
* Starts the speed notification.
* @param currentConfig The current profile configuration.
*/
fun startSpeedNotification(currentConfig: ProfileItem?) {
fun startSpeedNotification() {
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()
outboundTags?.remove(AppConfig.TAG_DIRECT)
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
val queryTime = System.currentTimeMillis()
val sinceLastQueryIn = (queryTime - lastQueryTime)
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
Log.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
delay(QUERY_INTERVAL_MS)
continue
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
val down = V2RayServiceManager.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 zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
if (!zeroSpeed || !lastZeroSpeed) {
if (proxyTotal == 0L) {
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
}
appendSpeedString(
text, AppConfig.TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
}
lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime
lastZeroSpeed = updateSpeedNotificationOnce(lastZeroSpeed)
delay(QUERY_INTERVAL_MS)
}
}
@@ -167,13 +130,12 @@ object NotificationManager {
/**
* Stops the speed notification.
* @param currentConfig The current profile configuration.
*/
fun stopSpeedNotification(currentConfig: ProfileItem?) {
fun stopSpeedNotification() {
speedNotificationJob?.let {
it.cancel()
speedNotificationJob = null
updateNotification(currentConfig?.remarks, 0, 0)
updateNotification("", 0, 0)
}
}
@@ -246,11 +208,74 @@ object NotificationManager {
text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
}
/**
* Updates the speed notification once.
* Queries traffic stats, separates proxy and direct, and updates the notification.
* @param lastZeroSpeed The previous zero speed state.
* @return The current zero speed state.
*/
private fun updateSpeedNotificationOnce(lastZeroSpeed: Boolean): Boolean {
val queryTime = System.currentTimeMillis()
val sinceLastQueryIn = (queryTime - lastQueryTime)
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
return lastZeroSpeed
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyUplink = 0L
var proxyDownlink = 0L
var directUplink = 0L
var directDownlink = 0L
CoreServiceManager.queryAllOutboundTrafficStats().forEach { stat ->
when {
stat.tag == AppConfig.TAG_DIRECT -> {
when (stat.direction) {
AppConfig.UPLINK -> directUplink += stat.value
AppConfig.DOWNLINK -> directDownlink += stat.value
}
}
stat.tag.startsWith(AppConfig.TAG_PROXY) -> {
when (stat.direction) {
AppConfig.UPLINK -> proxyUplink += stat.value
AppConfig.DOWNLINK -> proxyDownlink += stat.value
}
}
}
}
val proxyTotal = proxyUplink + proxyDownlink
val directTotal = directUplink + directDownlink
val zeroSpeed = proxyTotal + directTotal == 0L
if (!zeroSpeed || !lastZeroSpeed) {
val text = StringBuilder()
appendSpeedString(
text, AppConfig.TAG_PROXY,
proxyUplink / sinceLastQueryInSeconds,
proxyDownlink / sinceLastQueryInSeconds
)
appendSpeedString(
text, AppConfig.TAG_DIRECT,
directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directTotal)
}
lastQueryTime = queryTime
return zeroSpeed
}
/**
* Gets the service instance.
* @return The service instance.
*/
private fun getService(): Service? {
return V2RayServiceManager.serviceControl?.get()?.getService()
return CoreServiceManager.serviceControl?.get()?.getService()
}
}
@@ -2,8 +2,8 @@ package com.v2ray.ang.handler
import android.content.Context
import android.content.res.AssetManager
import android.os.Build
import android.text.TextUtils
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
@@ -12,10 +12,10 @@ import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.enums.RoutingType
@@ -27,14 +27,19 @@ import com.v2ray.ang.handler.MmkvManager.decodeSubscription
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
import com.v2ray.ang.handler.MmkvManager.removeSubscription
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.io.File
import java.io.FileOutputStream
import java.util.Collections
import java.util.Locale
import kotlin.random.Random
object SettingsManager {
@Volatile
private var runtimeSocksPort: Int? = null
fun initApp(context: Context) {
ensureDefaultSettings()
//ensureDefaultSubscription()
@@ -68,7 +73,7 @@ object SettingsManager {
return null
}
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java)?.toMutableList()
return JsonUtil.fromJsonSafe(assets, Array<RulesetItem>::class.java)?.toMutableList()
}
/**
@@ -92,7 +97,7 @@ object SettingsManager {
}
try {
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java)?.toMutableList()
val rulesetList = JsonUtil.fromJsonSafe(content, Array<RulesetItem>::class.java)?.toMutableList()
if (rulesetList.isNullOrEmpty()) {
return false
}
@@ -100,7 +105,7 @@ object SettingsManager {
resetRoutingRulesetsCommon(rulesetList)
return true
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
LogUtil.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
return false
}
}
@@ -186,7 +191,7 @@ object SettingsManager {
val config = decodeServerConfig(guid) ?: return false
if (config.configType == EConfigType.CUSTOM) {
val raw = MmkvManager.decodeServerRaw(guid) ?: return false
val v2rayConfig = JsonUtil.fromJson(raw, V2rayConfig::class.java)
val v2rayConfig = JsonUtil.fromJsonSafe(raw, V2rayConfig::class.java)
val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any {
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
}
@@ -219,7 +224,7 @@ object SettingsManager {
* @param toPosition The position to swap to.
*/
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
val subsList = MmkvManager.decodeSubsList()
val subsList = decodeSubsList()
if (subsList.isEmpty()) return
Collections.swap(subsList, fromPosition, toPosition)
@@ -241,18 +246,27 @@ object SettingsManager {
.firstOrNull { it.remarks == remarks }
}
/**
* Collects non-empty profile remarks while excluding specific config types.
*/
fun getProfileRemarks(excludeConfigTypes: Set<EConfigType> = setOf(EConfigType.CUSTOM)): List<String> {
return decodeAllServerList()
.asSequence()
.mapNotNull { guid -> decodeServerConfig(guid) }
.filter { profile -> profile.configType !in excludeConfigTypes }
.map { it.remarks.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
}
/**
* Removes the subscription.
* If there are no remaining subscriptions,
* 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) {
// Log.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
// return
// }
SubscriptionUpdater.cancelOne(subId = subid)
// Remove the subscription
removeSubscription(subid)
@@ -273,7 +287,30 @@ object SettingsManager {
* @return The SOCKS port.
*/
fun getSocksPort(): Int {
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
val port =
if (IsDynamicSocksPort()) {
runtimeSocksPort ?: refreshRuntimeSocksPort()
} else {
Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
}
return port ?: AppConfig.PORT_SOCKS.toInt()
}
@Synchronized
fun refreshRuntimeSocksPort(): Int? {
if (IsDynamicSocksPort()) {
runtimeSocksPort = generateRandomSocksPort()
return runtimeSocksPort
}
return null
}
fun getSocksUsername(): String? {
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_USERNAME)?.trim()?.takeIf { it.isNotEmpty() }
}
fun getSocksPassword(): String? {
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PASSWORD)?.trim()?.takeIf { it.isNotEmpty() }
}
/**
@@ -284,6 +321,14 @@ object SettingsManager {
return getSocksPort() + if (Utils.isXray()) 0 else 1
}
private fun IsDynamicSocksPort(): Boolean {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
}
private fun generateRandomSocksPort(): Int {
return Random.nextInt(10000, 65535)
}
/**
* Initialize assets.
* @param context The application context.
@@ -304,10 +349,10 @@ object SettingsManager {
input.copyTo(output)
}
}
Log.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
LogUtil.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
}
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "asset copy failed", e)
LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
}
}
@@ -362,6 +407,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.
@@ -433,15 +487,36 @@ object SettingsManager {
return mode == null || mode == VPN
}
/**
* Check if process routing can be used.
*/
fun canUseProcessRouting(): Boolean {
// Android 10+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return false
}
// Must xray tun
if (isUsingHevTun()) {
return false
}
// Must have route only enabled
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
return false
}
return true
}
/**
* Ensure default settings are present in MMKV.
*/
private fun ensureDefaultSettings() {
// Write defaults in the exact order requested by the user
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
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)
@@ -510,7 +585,7 @@ object SettingsManager {
return
}
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
val guids = JsonUtil.fromJsonSafe(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true)
return
}
@@ -1,13 +1,11 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import java.io.IOException
@@ -62,11 +60,11 @@ object SpeedtestManager {
socket.close()
return time
} catch (e: UnknownHostException) {
Log.e(AppConfig.TAG, "Unknown host: $url", e)
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
} catch (e: IOException) {
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
}
return -1
}
@@ -83,50 +81,24 @@ object SpeedtestManager {
}
}
/**
* Tests the connection to a given URL and port.
*
* @param context The Context in which the test is running.
* @param port The port to connect to.
* @return A pair containing the elapsed time in milliseconds and the result message.
*/
fun testConnection(context: Context, port: Int): Pair<Long, String> {
var result: String
var elapsed = -1L
val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
try {
val start = SystemClock.elapsedRealtime()
val code = conn.responseCode
elapsed = SystemClock.elapsedRealtime() - start
result = when (code) {
204 -> context.getString(R.string.connection_test_available, elapsed)
200 if conn.contentLengthLong == 0L -> context.getString(R.string.connection_test_available, elapsed)
else -> throw IOException(
context.getString(R.string.connection_test_error_status_code, code)
)
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Connection test IOException", e)
result = context.getString(R.string.connection_test_error, e.message)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Connection test Exception", e)
result = context.getString(R.string.connection_test_error, e.message)
} finally {
conn.disconnect()
}
return Pair(elapsed, result)
}
fun getRemoteIPInfo(): String? {
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
val content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: return null
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
if (httpPort == 0) return null
val content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
) ?: return null
val ipInfo = JsonUtil.fromJsonSafe(content, IPAPIInfo::class.java) ?: return null
val ip = listOf(
ipInfo.ip,
@@ -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 android.util.Log
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.entities.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 {
Log.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())
Log.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,19 +1,17 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.Build
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.dto.GitHubRelease
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.util.HttpUtil
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) {
@@ -23,26 +21,42 @@ object UpdateCheckerManager {
AppConfig.APP_API_URL.concatUrl("latest")
}
var response = HttpUtil.getUrlContent(url, 5000)
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var response = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000
)
)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort)
response = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
)
?: throw IllegalStateException("Failed to get response")
}
val latestRelease = if (includePreRelease) {
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
JsonUtil.fromJsonSafe(response, Array<GitHubRelease>::class.java)
?.firstOrNull()
?: throw IllegalStateException("No pre-release found")
} else {
JsonUtil.fromJson(response, GitHubRelease::class.java)
JsonUtil.fromJsonSafe(response, GitHubRelease::class.java)
}
if (latestRelease == null) {
return@withContext CheckUpdateResult(hasUpdate = false)
}
val latestVersion = latestRelease.tagName.removePrefix("v")
Log.i(
LogUtil.i(
AppConfig.TAG,
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
)
@@ -61,39 +75,6 @@ object UpdateCheckerManager {
}
}
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try {
val httpPort = SettingsManager.getHttpPort()
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
?: throw IllegalStateException("Failed to create connection")
try {
val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Log.i(AppConfig.TAG, "APK download completed")
return@withContext apkFile
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
return@withContext null
} finally {
try {
connection.disconnect()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
return@withContext null
}
}
private fun compareVersions(version1: String, version2: String): Int {
val v1 = version1.split(".")
val v2 = version2.split(".")
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,8 @@
package com.v2ray.ang.handler
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Credentials
@@ -67,14 +67,14 @@ object WebDavManager {
cl.newCall(req).execute().use { resp ->
val success = resp.isSuccessful
if (success) {
Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
} else {
Log.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
LogUtil.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
}
return@withContext success
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
return@withContext false
}
}
@@ -93,7 +93,7 @@ object WebDavManager {
val req = applyAuth(Request.Builder().url(remote).get()).build()
cl.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
Log.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
LogUtil.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
return@withContext false
}
@@ -104,11 +104,11 @@ object WebDavManager {
}
}
Log.i(AppConfig.TAG, "WebDAV download success: $remote")
LogUtil.i(AppConfig.TAG, "WebDAV download success: $remote")
return@withContext true
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
return@withContext false
}
}
@@ -169,7 +169,7 @@ object WebDavManager {
cl.newCall(req).execute().use { resp ->
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
LogUtil.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
}
}
} catch (_: Exception) {
@@ -177,7 +177,7 @@ object WebDavManager {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
LogUtil.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
}
}
}
@@ -3,13 +3,13 @@ package com.v2ray.ang.helper
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
import com.v2ray.ang.util.LogUtil
/**
* Helper for choosing and creating files using Android Storage Access Framework.
@@ -58,7 +58,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
fileChooserCallback?.invoke(null)
fileChooserCallback = null
@@ -79,7 +79,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
try {
documentCreateLauncher.launch(fileName)
} catch (ex: ActivityNotFoundException) {
Log.e(AppConfig.TAG, "Document creator activity not found", ex)
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
documentCreateCallback?.invoke(null)
documentCreateCallback = null
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
/**
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
@@ -73,6 +74,10 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
// Internal helper: notify other modules about setting changes
private fun notifySettingChanged(key: String) {
if (key == AppConfig.PREF_LOGLEVEL) {
LogUtil.refreshLogLevel()
}
// Call SettingsManager.setNightMode if UI mode changed
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
SettingsManager.setNightMode()
@@ -3,10 +3,11 @@ package com.v2ray.ang.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
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() {
/**
@@ -18,24 +19,25 @@ class BootReceiver : BroadcastReceiver() {
* @param intent The Intent being received.
*/
override fun onReceive(context: Context?, intent: Intent?) {
Log.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
LogUtil.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
Log.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
LogUtil.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
return
}
if (!MmkvManager.decodeStartOnBoot()) {
Log.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
LogUtil.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
return
}
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
Log.w(AppConfig.TAG, "BootReceiver: No server selected")
LogUtil.w(AppConfig.TAG, "BootReceiver: No server selected")
return
}
Log.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
V2RayServiceManager.startVService(context)
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
CoreServiceManager.startVService(context)
SubscriptionUpdater.sync(context)
}
}
@@ -5,7 +5,8 @@ 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() {
@@ -27,15 +28,15 @@ 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) {
android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
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 ->
@@ -4,22 +4,22 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Log
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()
Log.i(AppConfig.TAG, "StartCore-Proxy: Service created")
V2RayServiceManager.serviceControl = SoftReference(this)
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
CoreServiceManager.serviceControl = SoftReference(this)
}
/**
@@ -30,8 +30,8 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
V2RayServiceManager.startCoreLoop(null)
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
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()
}
}
@@ -14,23 +14,23 @@ import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.StrictMode
import android.util.Log
import androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig
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
@@ -74,14 +74,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onCreate() {
super.onCreate()
Log.i(AppConfig.TAG, "StartCore-VPN: Service created")
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() {
Log.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
stopAllService()
}
@@ -92,12 +92,28 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onDestroy() {
super.onDestroy()
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
// Ensure VPN interface is properly closed when the service is destroyed without
// going through stopAllService() (e.g. when killed unexpectedly). isRunning is
// set to false at the start of stopAllService(), so this guard prevents a double-close.
if (isRunning) {
try {
if (::mInterface.isInitialized) {
mInterface.close()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: VPN interface closed in onDestroy")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface in onDestroy", e)
}
}
NotificationManager.cancelNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(AppConfig.TAG, "StartCore-VPN: Service command received")
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
NotificationManager.showNotification(null)
setupVpnService()
startService()
return START_STICKY
@@ -110,11 +126,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun startService() {
if (!::mInterface.isInitialized) {
Log.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
return
}
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
if (!CoreServiceManager.startCoreLoop(mInterface)) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
stopAllService()
return
}
@@ -142,13 +158,13 @@ class V2RayVpnService : VpnService(), ServiceControl {
private fun setupVpnService() {
val prepare = prepare(this)
if (prepare != null) {
Log.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
stopSelf()
return
}
if (configureVpnService() != true) {
Log.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
stopSelf()
return
}
@@ -175,7 +191,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
mInterface.close()
}
} catch (e: Exception) {
Log.w(AppConfig.TAG, "Failed to close old interface", e)
LogUtil.w(AppConfig.TAG, "Failed to close old interface", e)
}
// Configure platform-specific features
@@ -187,7 +203,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
isRunning = true
return true
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
LogUtil.e(AppConfig.TAG, "Failed to establish VPN interface", e)
stopAllService()
}
return false
@@ -218,7 +234,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
// Configure IPv6 if enabled
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
builder.addAddress(vpnConfig.ipv6Client, 126)
if (bypassLan) {
builder.addRoute("2000::", 3) // Currently only 1/8 of total IPv6 is in use
@@ -238,7 +254,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
}
builder.setSession(V2RayServiceManager.getRunningServerName())
//builder.setSession(V2RayServiceManager.getRunningServerName())
}
/**
@@ -252,7 +268,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
try {
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
}
}
@@ -305,7 +321,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
builder.addAllowedApplication(it)
}
} catch (e: PackageManager.NameNotFoundException) {
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
}
}
}
@@ -339,14 +355,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
try {
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
} catch (e: Exception) {
Log.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
}
}
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
@@ -356,12 +372,22 @@ class V2RayVpnService : VpnService(), ServiceControl {
//which means the first v2ray core somehow failed to stop and release the port.
stopSelf()
// Add a small delay to allow the async core stop operation to complete
// before closing the VPN interface, preventing a race condition that can
// leave the VPN icon in the status bar after stopping the service.
try {
Thread.sleep(100)
} catch (e: InterruptedException) {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Sleep interrupted", e)
}
try {
if (::mInterface.isInitialized) {
mInterface.close()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: VPN interface closed")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
}
}
}
@@ -0,0 +1,774 @@
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")
private val HEADERS_BLACKLIST = hashSetOf(
// AI suggest:
"host",
"content-length",
"transfer-encoding",
"content-encoding",
"connection",
"upgrade",
"sec-websocket-key",
"sec-websocket-version",
"sec-websocket-protocol",
"Timing-Allow-Origin",
// xray
"Set-Cookie",
"Cookie",
"Origin",
"Sec-CH-UA",
"Sec-CH-UA-Mobile",
"Sec-CH-UA-Platform",
"DNT",
"User-Agent",
"Accept-Language",
"Cache-Control",
"Upgrade-Insecure-Requests",
"Sec-Fetch-Site",
"Sec-Fetch-Mode",
"Sec-Fetch-User",
"Sec-Fetch-Dest",
"Referer",
"Accept",
"Priority",
"Pragma",
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Access-Control-Expose-Headers",
"Access-Control-Max-Age",
)
}
@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 taskHeaders = task.extra.headers.filterKeys { key ->
val lowerKey = key.lowercase()
!HEADERS_BLACKLIST.any { blackKey -> blackKey.equals(lowerKey, ignoreCase = true) }
}
taskHeaders.forEach { (key, value) -> requestBuilder.header(key, value) }
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()
}
@@ -1,8 +1,8 @@
package com.v2ray.ang.service
import android.content.Context
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -16,7 +16,7 @@ class ProcessService {
* @param cmd The command to run.
*/
fun runProcess(context: Context, cmd: MutableList<String>) {
Log.i(AppConfig.TAG, cmd.toString())
LogUtil.i(AppConfig.TAG, cmd.toString())
try {
val proBuilder = ProcessBuilder(cmd)
@@ -27,14 +27,14 @@ class ProcessService {
CoroutineScope(Dispatchers.IO).launch {
Thread.sleep(50L)
Log.i(AppConfig.TAG, "runProcess check")
LogUtil.i(AppConfig.TAG, "runProcess check")
process?.waitFor()
Log.i(AppConfig.TAG, "runProcess exited")
LogUtil.i(AppConfig.TAG, "runProcess exited")
}
Log.i(AppConfig.TAG, process.toString())
LogUtil.i(AppConfig.TAG, process.toString())
} catch (e: Exception) {
Log.e(AppConfig.TAG, e.toString(), e)
LogUtil.e(AppConfig.TAG, e.toString(), e)
}
}
@@ -43,10 +43,10 @@ class ProcessService {
*/
fun stopProcess() {
try {
Log.i(AppConfig.TAG, "runProcess destroy")
LogUtil.i(AppConfig.TAG, "runProcess destroy")
process?.destroy()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to destroy process", e)
LogUtil.e(AppConfig.TAG, "Failed to destroy process", e)
}
}
}
@@ -7,11 +7,11 @@ import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
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
import java.lang.ref.SoftReference
@@ -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)
@@ -63,7 +63,7 @@ class QSTileService : TileService() {
applicationContext.unregisterReceiver(mMsgReceive)
mMsgReceive = null
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unregister receiver", e)
LogUtil.e(AppConfig.TAG, "Failed to unregister receiver", e)
}
}
@@ -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())
}
}
@@ -2,11 +2,11 @@ package com.v2ray.ang.service
import android.content.Context
import android.os.ParcelFileDescriptor
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import java.io.File
/**
@@ -22,9 +22,11 @@ class TProxyService(
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStartService(configPath: String, fd: Int)
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStopService()
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyGetStats(): LongArray?
@@ -38,32 +40,36 @@ class TProxyService(
* Starts the tun2socks process with the appropriate parameters.
*/
override fun startTun2Socks() {
// Log.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
// LogUtil.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
val configContent = buildConfig()
val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
writeText(configContent)
}
// Log.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
Log.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
// LogUtil.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
LogUtil.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
try {
// Log.i(AppConfig.TAG, "TProxyStartService...")
// LogUtil.i(AppConfig.TAG, "TProxyStartService...")
TProxyStartService(configFile.absolutePath, vpnInterface.fd)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
LogUtil.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
}
}
private fun buildConfig(): String {
val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
val escapedSocksUsername = socksUsername?.replace("'", "''")
val escapedSocksPassword = socksPassword?.replace("'", "''")
return buildString {
appendLine("tunnel:")
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
appendLine(" ipv4: ${vpnConfig.ipv4Client}")
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)) {
appendLine(" ipv6: '${vpnConfig.ipv6Client}'")
}
@@ -71,6 +77,10 @@ class TProxyService(
appendLine(" port: ${socksPort}")
appendLine(" address: ${AppConfig.LOOPBACK}")
appendLine(" udp: 'udp'")
if (escapedSocksUsername != null && escapedSocksPassword != null) {
appendLine(" username: '${escapedSocksUsername}'")
appendLine(" password: '${escapedSocksPassword}'")
}
// Read-write timeout settings
val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT
@@ -92,10 +102,10 @@ class TProxyService(
*/
override fun stopTun2Socks() {
try {
Log.i(AppConfig.TAG, "TProxyStopService...")
LogUtil.i(AppConfig.TAG, "TProxyStopService...")
TProxyStopService()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
LogUtil.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
}
}
}
@@ -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 {
@@ -0,0 +1,197 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
class AppPickerActivity : BaseActivity() {
companion object {
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
private const val EXTRA_PICKER_TITLE = "picker_title"
fun createIntent(
context: Context,
selectedPackages: Collection<String> = emptyList(),
title: String? = null
): Intent = Intent(context, AppPickerActivity::class.java).apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(selectedPackages))
title?.let { putExtra(EXTRA_PICKER_TITLE, it) }
}
fun getSelectedPackages(intent: Intent?): List<String> {
return intent?.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
}
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
private val initialSelectedPackages by lazy {
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
private val selectedPackages = LinkedHashSet<String>()
private var appsAll: List<AppInfo> = emptyList()
private val adapter = AppSelectorAdapter(selectedPackages)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
selectedPackages.addAll(initialSelectedPackages)
setupRecyclerView()
loadApps()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_app_picker, menu)
val searchItem = menu.findItem(R.id.search_view)
if (searchItem != null) {
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
filterApps(newText.orEmpty())
return false
}
})
}
return true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.select_all -> {
selectAllVisible()
true
}
R.id.invert_selection -> {
invertVisibleSelection()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun finish() {
setResult(
RESULT_OK,
Intent().apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
}
)
super.finish()
}
private fun setupRecyclerView() {
binding.recyclerView.adapter = adapter
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
}
@SuppressLint("UseCompatLoadingForDrawables")
private fun createSpecialItemUnidentified(): AppInfo {
val icon = requireNotNull(
getDrawable(android.R.drawable.ic_menu_help)
?: getDrawable(android.R.drawable.sym_def_app_icon)
) { "No fallback drawable available" }
return AppInfo(
appName = getString(R.string.app_picker_unknown_app),
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false,
isSelected = 0
)
}
private fun loadApps() {
showLoading()
lifecycleScope.launch {
try {
val apps = withContext(Dispatchers.IO) {
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
val sortedApps = sortApps(appsList)
listOf(createSpecialItemUnidentified()) + sortedApps
}
appsAll = apps
updateDisplayedApps(apps)
} catch (e: Exception) {
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
} finally {
hideLoading()
}
}
}
private fun filterApps(content: String) {
val key = content.uppercase()
val filteredApps = appsAll.filter { app ->
key.isEmpty() || matchesSearch(app, key)
}
updateDisplayedApps(filteredApps)
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
return apps.sortedWith { p1, p2 ->
val p1Selected = selectedPackages.contains(p1.packageName)
val p2Selected = selectedPackages.contains(p2.packageName)
when {
p1Selected && !p2Selected -> -1
!p1Selected && p2Selected -> 1
p1.isSystemApp && !p2.isSystemApp -> 1
!p1.isSystemApp && p2.isSystemApp -> -1
else -> collator.compare(p1.appName, p2.appName)
}
}
}
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
}
private fun updateDisplayedApps(apps: List<AppInfo>) {
adapter.submitList(apps)
}
private fun selectAllVisible() {
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
adapter.refreshSelection()
}
private fun invertVisibleSelection() {
adapter.apps.forEach { app ->
if (selectedPackages.contains(app.packageName)) {
selectedPackages.remove(app.packageName)
} else {
selectedPackages.add(app.packageName)
}
}
adapter.refreshSelection()
}
private fun getSelectedPackages(): ArrayList<String> {
return ArrayList(selectedPackages.sorted())
}
private fun resolveScreenTitle(): String {
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
}
}
@@ -0,0 +1,64 @@
package com.v2ray.ang.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo
class AppSelectorAdapter(
private val selectedPackages: MutableSet<String>
) : RecyclerView.Adapter<AppSelectorAdapter.AppViewHolder>() {
var apps: List<AppInfo> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
val binding = ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AppViewHolder(binding)
}
override fun getItemCount(): Int = apps.size
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
holder.bind(apps[position])
}
fun submitList(newApps: List<AppInfo>) {
apps = newApps
notifyDataSetChanged()
}
fun refreshSelection() {
notifyDataSetChanged()
}
inner class AppViewHolder(private val binding: ItemRecyclerBypassListBinding) : RecyclerView.ViewHolder(binding.root),
View.OnClickListener {
private lateinit var appInfo: AppInfo
fun bind(item: AppInfo) {
appInfo = item
binding.icon.setImageDrawable(item.appIcon)
binding.name.text = if (item.isSystemApp) {
String.format("** %s", item.appName)
} else {
item.appName
}
binding.packageName.text = item.packageName
binding.checkBox.isChecked = selectedPackages.contains(item.packageName)
itemView.setOnClickListener(this)
}
override fun onClick(v: View?) {
val packageName = appInfo.packageName
if (selectedPackages.contains(packageName)) {
selectedPackages.remove(packageName)
} else {
selectedPackages.add(packageName)
}
binding.checkBox.isChecked = selectedPackages.contains(packageName)
}
}
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV
@@ -13,13 +12,14 @@ import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBackupBinding
import com.v2ray.ang.databinding.DialogWebdavBinding
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.extension.toastError
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.WebDavManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -38,7 +38,7 @@ class BackupActivity : HelperBaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
binding.layoutBackup.setOnClickListener {
@@ -149,7 +149,7 @@ class BackupActivity : HelperBaseActivity() {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error during file restore", e)
LogUtil.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
}
}
@@ -180,7 +180,7 @@ class BackupActivity : HelperBaseActivity() {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
LogUtil.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
@@ -217,7 +217,7 @@ class BackupActivity : HelperBaseActivity() {
val ok = try {
WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload error", e)
LogUtil.e(AppConfig.TAG, "WebDAV upload error", e)
false
}
@@ -225,7 +225,7 @@ class BackupActivity : HelperBaseActivity() {
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV backup error", e)
LogUtil.e(AppConfig.TAG, "WebDAV backup error", e)
withContext(Dispatchers.Main) {
toastError(R.string.toast_failure)
}
@@ -272,7 +272,7 @@ class BackupActivity : HelperBaseActivity() {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV download error", e)
LogUtil.e(AppConfig.TAG, "WebDAV download error", e)
withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
} finally {
try {
@@ -39,7 +39,7 @@ abstract class BaseFragment<VB : ViewBinding> : Fragment() {
* @param drawableResId The resource ID of the drawable to be used as the divider.
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
*/
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
@@ -1,12 +1,12 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AlertDialog
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
@@ -14,7 +14,7 @@ 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
}
@@ -56,10 +56,9 @@ class CheckUpdateActivity : BaseActivity() {
toastSuccess(R.string.update_already_latest_version)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
toastError(e.message ?: getString(R.string.toast_failure))
}
finally {
} finally {
hideLoading()
}
}
@@ -2,10 +2,10 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import android.util.Log
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
@@ -17,14 +17,17 @@ import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -44,6 +47,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"
@@ -70,19 +78,20 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
binding.refreshLayout.setOnRefreshListener(this)
// Set the distance to trigger sync to 160dp
binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
binding.refreshLayout.isEnabled = false
// binding.refreshLayout.setOnRefreshListener(this)
// // Set the distance to trigger sync to 160dp
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId != subId) {
return@observe
}
// Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
// LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index)
}
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
// LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
}
override fun onResume() {
@@ -111,7 +120,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error when sharing server", e)
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
@@ -167,23 +176,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)
}
/**
@@ -264,7 +270,7 @@ 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.isComplexType()
val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
@@ -280,7 +286,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
override fun onRefresh() {
ownerActivity.importConfigViaSub()
binding.refreshLayout.isRefreshing = false
//binding.refreshLayout.isRefreshing = false
}
/**
@@ -20,9 +20,9 @@ import com.v2ray.ang.helper.QRCodeScannerHelper
* - Provide QR code scanning helpers with camera permission handling.
*/
abstract class HelperBaseActivity : BaseActivity() {
private lateinit var fileChooser : FileChooserHelper
private lateinit var permissionRequester : PermissionHelper
private lateinit var qrCodeScanner : QRCodeScannerHelper
private lateinit var fileChooser: FileChooserHelper
private lateinit var permissionRequester: PermissionHelper
private lateinit var qrCodeScanner: QRCodeScannerHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -56,9 +56,7 @@ abstract class HelperBaseActivity : BaseActivity() {
mimeType: String = "*/*",
onResult: (Uri?) -> Unit
) {
checkAndRequestPermission(PermissionType.READ_STORAGE) {
fileChooser.launch(mimeType, onResult)
}
fileChooser.launch(mimeType, onResult)
}
/**
@@ -1,6 +1,8 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
@@ -18,6 +20,11 @@ import com.v2ray.ang.viewmodel.LogcatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.core.content.FileProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
@@ -45,6 +52,55 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
return true
}
private fun shareLogcat() {
lifecycleScope.launch(Dispatchers.IO) {
val logText = viewModel.getAll().joinToString("\n")
val result = try {
val shareDir = File(cacheDir, "shared_logs").apply {
mkdirs()
}
shareDir.listFiles()?.forEach { it.delete() }
val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
val logFile = File(shareDir, "v2rayNG_logcat_$timestamp.txt")
logFile.writeText(logText, Charsets.UTF_8)
val uri = FileProvider.getUriForFile(
this@LogcatActivity,
"${packageName}.cache",
logFile
)
uri to logFile.name
} catch (e: Exception) {
withContext(Dispatchers.Main) {
toast(e.localizedMessage ?: e.toString())
}
return@launch
}
withContext(Dispatchers.Main) {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, result.first)
putExtra(Intent.EXTRA_SUBJECT, result.second)
putExtra(Intent.EXTRA_TITLE, result.second)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
clipData = ClipData.newUri(contentResolver, result.second, result.first)
}
startActivity(
Intent.createChooser(
shareIntent,
getString(R.string.logcat_share)
)
)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_logcat, menu)
@@ -78,6 +134,11 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
true
}
R.id.share_all -> {
shareLogcat()
true
}
R.id.clear_all -> {
lifecycleScope.launch(Dispatchers.IO) {
viewModel.clearLogcat()
@@ -1,11 +1,11 @@
package com.v2ray.ang.ui
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.LogcatViewModel
class LogcatRecyclerAdapter(
@@ -33,7 +33,7 @@ class LogcatRecyclerAdapter(
onLongClick?.invoke(log) ?: false
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error binding log view data", e)
LogUtil.e(AppConfig.TAG, "Error binding log view data", e)
}
}
@@ -5,7 +5,6 @@ import android.content.res.ColorStateList
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
@@ -23,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
@@ -32,7 +32,8 @@ 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
import kotlinx.coroutines.Dispatchers
@@ -94,11 +95,11 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
})
binding.fab.setOnClickListener { handleFabAction() }
binding.fabLocate.setOnClickListener { locateSelectedServer() }
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
setupGroupTab()
setupViewModel()
SubscriptionUpdater.sync()
mainViewModel.reloadServerList()
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
@@ -136,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) {
@@ -163,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)
@@ -180,7 +181,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
binding.tvTestState.text = content
}
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
if (isLoading) {
binding.fab.setImageResource(R.drawable.ic_fab_check)
return
@@ -253,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
@@ -340,6 +346,10 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
true
}
R.id.locate_selected_config -> {
locateSelectedServer()
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -351,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()
@@ -382,7 +398,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
val clipboard = Utils.getClipboard(this)
importBatchConfig(clipboard)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import config from clipboard", e)
LogUtil.e(AppConfig.TAG, "Failed to import config from clipboard", e)
return false
}
return true
@@ -412,7 +428,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
toastError(R.string.toast_failure)
hideLoading()
}
Log.e(AppConfig.TAG, "Failed to import batch config", e)
LogUtil.e(AppConfig.TAG, "Failed to import batch config", e)
}
}
}
@@ -424,7 +440,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import config from local file", e)
LogUtil.e(AppConfig.TAG, "Failed to import config from local file", e)
return false
}
return true
@@ -566,7 +582,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
importBatchConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
LogUtil.e(AppConfig.TAG, "Failed to read content from URI", e)
}
}
@@ -1,3 +1,4 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
@@ -12,8 +13,9 @@ import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
@@ -58,7 +60,7 @@ class MainRecyclerAdapter(
//Name address
holder.itemMainBinding.tvName.text = profile.remarks
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
holder.itemMainBinding.tvType.text = profile.configType.name
holder.itemMainBinding.tvType.text = getProtocolDescription(profile)
//TestResult
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
@@ -113,7 +115,7 @@ class MainRecyclerAdapter(
adapterListener?.onSelectServer(guid)
}
}
}
/**
@@ -140,6 +142,31 @@ class MainRecyclerAdapter(
return subRemarks?.toString() ?: ""
}
private fun getProtocolDescription(profile: ProfileItem): String {
if (profile.configType.isComplexType()) {
return profile.configType.name
}
val parts = mutableListOf<String>()
parts.add(profile.configType.name)
// Transport: hide tcp or blank
profile.network?.let { net ->
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) {
parts.add(net)
}
}
// Security: hide blank or tls
profile.security?.let { sec ->
if (sec.isNotBlank() && !sec.equals("tls", ignoreCase = true)) {
parts.add(sec)
}
}
return parts.joinToString(" / ")
}
fun removeServerSub(guid: String, position: Int) {
val idx = data.indexOfFirst { it.guid == guid }
if (idx >= 0) {
@@ -203,4 +230,4 @@ class MainRecyclerAdapter(
override fun onItemDismiss(position: Int) {
}
}
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
@@ -15,6 +14,7 @@ import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBypassListBinding
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.extension.v2RayApplication
@@ -23,6 +23,7 @@ import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
import es.dmoral.toasty.Toasty
@@ -99,7 +100,7 @@ class PerAppProxyActivity : BaseActivity() {
binding.recyclerView.adapter = adapter
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "Error loading apps", e)
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
} finally {
hideLoading()
}
@@ -133,6 +134,7 @@ class PerAppProxyActivity : BaseActivity() {
allowPerAppProxy()
true
}
R.id.invert_selection -> {
invertSelection()
allowPerAppProxy()
@@ -188,13 +190,28 @@ class PerAppProxyActivity : BaseActivity() {
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
lifecycleScope.launch(Dispatchers.IO) {
var content = HttpUtil.getUrlContent(url, 5000)
var content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000
)
)
if (content.isNullOrEmpty()) {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
) ?: ""
}
launch(Dispatchers.Main) {
//Log.i(AppConfig.TAG, content)
//LogUtil.i(AppConfig.TAG, content)
selectProxyApp(content, true)
toastSuccess(R.string.toast_success)
hideLoading()
@@ -258,7 +275,7 @@ class PerAppProxyActivity : BaseActivity() {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error selecting proxy app", e)
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
return false
}
return true
@@ -11,7 +11,7 @@ import com.v2ray.ang.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(
val apps: List<AppInfo>,
val viewModel: PerAppProxyViewModel
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object {
private const val VIEW_TYPE_HEADER = 0
@@ -3,11 +3,15 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
@@ -19,22 +23,77 @@ import kotlinx.coroutines.launch
class RoutingEditActivity : BaseActivity() {
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
private val position by lazy { intent.getIntExtra("position", -1) }
private val outbound_tag: Array<out String> by lazy {
resources.getStringArray(R.array.outbound_tag)
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
setupOutboundTagInput()
setupProcessPicker()
val rulesetItem = SettingsManager.getRoutingRuleset(position)
if (rulesetItem != null) {
bindingServer(rulesetItem)
} else {
clearServer()
}
SettingsManager.canUseProcessRouting().let { canUse ->
binding.etProcess.isEnabled = canUse
binding.btnProcessPicker.isEnabled = canUse
}
}
private fun setupProcessPicker() {
binding.btnProcessPicker.setOnClickListener {
processPickerLauncher.launch(
AppPickerActivity.createIntent(
context = this,
selectedPackages = getSelectedProcessPackages(),
title = getString(R.string.routing_settings_process)
)
)
}
}
private fun getSelectedProcessPackages(): List<String> {
return binding.etProcess.text
.toString()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
}
/**
* Sets up the AutoCompleteTextView for outbound tag:
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
* The dropdown button triggers showing the full list without typing.
*/
private fun setupOutboundTagInput() {
val profileRemarks = SettingsManager.getProfileRemarks()
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
binding.spOutboundTag.setAdapter(adapter)
// threshold=0 means show all suggestions even before typing; still need focus+request
binding.spOutboundTag.threshold = 0
// Dropdown arrow button shows the full suggestion list
binding.btnOutboundTagDropdown.setOnClickListener {
binding.spOutboundTag.requestFocus()
binding.spOutboundTag.showDropDown()
}
// Also show on field click when it already has focus
binding.spOutboundTag.setOnClickListener {
binding.spOutboundTag.showDropDown()
}
}
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
@@ -42,18 +101,18 @@ class RoutingEditActivity : BaseActivity() {
binding.chkLocked.isChecked = rulesetItem.locked == true
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
binding.etProcess.text = Utils.getEditable(rulesetItem.process?.joinToString(","))
binding.etPort.text = Utils.getEditable(rulesetItem.port)
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag)
binding.spOutboundTag.setSelection(outbound)
// Set text directly; filter won't fire because we're not using setText(filter=true)
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
return true
}
private fun clearServer(): Boolean {
binding.etRemarks.text = null
binding.spOutboundTag.setSelection(0)
binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
return true
}
@@ -65,10 +124,11 @@ class RoutingEditActivity : BaseActivity() {
locked = binding.chkLocked.isChecked
domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
process = binding.etProcess.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
port = binding.etPort.text.toString().nullIfBlank()
network = binding.etNetwork.text.toString().nullIfBlank()
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY }
}
if (rulesetItem.remarks.isNullOrEmpty()) {
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
@@ -21,6 +20,7 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
import kotlinx.coroutines.Dispatchers
@@ -92,7 +92,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
binding.tvDomainStrategySummary.text = value
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to set domain strategy", e)
LogUtil.e(AppConfig.TAG, "Failed to set domain strategy", e)
}
}.show()
}
@@ -110,7 +110,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
LogUtil.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
@@ -126,7 +126,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
val clipboard = try {
Utils.getClipboard(this)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
LogUtil.e(AppConfig.TAG, "Failed to get clipboard content", e)
toastError(R.string.toast_failure)
return@setPositiveButton
}
@@ -25,7 +25,7 @@ class RoutingSettingRecyclerAdapter(
val ruleset = rulesets[position]
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.port)?.toString()
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.process ?: ruleset.port)?.toString()
holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
@@ -0,0 +1,20 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
class ScStartActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (!CoreServiceManager.isRunning()) {
CoreServiceManager.startVServiceFromToggle(this)
}
finish()
}
}
@@ -0,0 +1,19 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
class ScStopActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
moveTaskToBack(true)
setContentView(R.layout.activity_none)
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()
}
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import com.v2ray.ang.AppConfig
@@ -11,6 +10,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityNoneBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanCustomCode
@@ -18,7 +18,7 @@ import io.github.g00fy2.quickie.config.BarcodeFormat
import io.github.g00fy2.quickie.config.ScannerConfig
class ScannerActivity : HelperBaseActivity() {
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
@@ -95,7 +95,7 @@ class ScannerActivity : HelperBaseActivity() {
finished(text)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
LogUtil.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
}
@@ -14,20 +14,21 @@ import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.DEFAULT_PORT
import com.v2ray.ang.AppConfig.PREF_ALLOW_INSECURE
import com.v2ray.ang.AppConfig.REALITY
import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
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.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
@@ -83,8 +84,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)
}
@@ -131,14 +132,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?) {
@@ -148,14 +153,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())
@@ -241,12 +244,28 @@ class ServerActivity : BaseActivity() {
else -> null
}.orEmpty()
)
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<*>?) {
@@ -276,7 +295,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 }
}
@@ -289,7 +307,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(
@@ -310,7 +327,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(
@@ -394,10 +410,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())
@@ -411,6 +423,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
}
@@ -439,6 +457,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
}
@@ -489,6 +508,13 @@ class ServerActivity : BaseActivity() {
}
}
if (et_fm?.text?.toString().isNotNullEmpty()) {
if (JsonUtil.parseString(et_fm?.text?.toString()) == null) {
toast(R.string.server_lab_final_mask)
return false
}
}
saveCommon(config)
saveStreamSettings(config)
saveTls(config)
@@ -498,8 +524,11 @@ class ServerActivity : BaseActivity() {
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty()
}
//Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
//LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
MmkvManager.encodeServerConfig(editGuid, config)
if (isRunning) {
SettingsChangeManager.makeRestartService()
}
toastSuccess(R.string.toast_success)
finish()
return true
@@ -533,7 +562,7 @@ class ServerActivity : BaseActivity() {
} else if (config.configType == EConfigType.HYSTERIA2) {
config.obfsPassword = et_obfs_password?.text?.toString()
config.portHopping = et_port_hop?.text?.toString()
config.portHoppingInterval = et_port_hop_interval?.text?.toString()
config.portHoppingInterval = et_port_hop_interval?.text?.toString()?.trim()
config.bandwidthDown = et_bandwidth_down?.text?.toString()
config.bandwidthUp = et_bandwidth_up?.text?.toString()
}
@@ -557,7 +586,20 @@ class ServerActivity : BaseActivity() {
profileItem.serviceName = path
profileItem.authority = requestHost
profileItem.xhttpMode = transportTypes(networks[network])[type]
profileItem.xhttpExtra = et_extra?.text?.toString()?.trim()
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) {
@@ -571,12 +613,11 @@ 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 =
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
MmkvManager.decodeSettingsBool(PREF_ALLOW_INSECURE)
false
} else {
allowinsecures[allowInsecureField].toBoolean()
}
@@ -591,7 +632,6 @@ class ServerActivity : BaseActivity() {
config.spiderX = spiderX
config.mldsa65Verify = mldsa65Verify
config.echConfigList = echConfigList
config.echForceQuery = echForceQuerys[echForceQueryIndex]
config.pinnedCA256 = pinnedCA256
}
@@ -648,17 +688,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)
}
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
@@ -11,13 +10,15 @@ import com.blacksquircle.ui.language.json.JsonLanguage
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
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
class ServerCustomConfigActivity : BaseActivity() {
@@ -79,7 +80,7 @@ class ServerCustomConfigActivity : BaseActivity() {
val profileItem = try {
CustomFmt.parse(binding.editor.text.toString())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom configuration", e)
LogUtil.e(AppConfig.TAG, "Failed to parse custom configuration", e)
toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
return false
}
@@ -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)
}
@@ -8,12 +8,13 @@ import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerGroupBinding
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
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() {
@@ -97,9 +98,12 @@ class ServerGroupActivity : BaseActivity() {
config.subscriptionId = subscriptionId.orEmpty()
}
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
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,217 @@
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.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
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 = SettingsManager.getProfileRemarks(
excludeConfigTypes = setOf(
EConfigType.CUSTOM,
EConfigType.POLICYGROUP,
EConfigType.PROXYCHAIN,
)
)
}
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.isComplexType()
}
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,14 +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 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
@@ -88,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 ->
@@ -112,15 +102,33 @@ class SettingsActivity : BaseActivity() {
updateHevTunSettings(newValue as Boolean)
true
}
enableLocalProxy?.setOnPreferenceChangeListener { _, newValue ->
updateEnableLocalProxy(newValue as Boolean)
true
}
dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
updateDynamicSocksPort(newValue as Boolean)
true
}
}
private fun initPreferenceSummaries() {
fun updateSummary(pref: androidx.preference.Preference) {
when (pref) {
is EditTextPreference -> {
pref.summary = pref.text.orEmpty()
if (pref.key == AppConfig.PREF_SOCKS_PASSWORD) {
pref.summary = if (pref.text.isNullOrEmpty()) "" else "******"
} else {
pref.summary = pref.text.orEmpty()
}
pref.setOnPreferenceChangeListener { p, newValue ->
p.summary = (newValue as? String).orEmpty()
if (p.key == AppConfig.PREF_SOCKS_PASSWORD) {
p.summary = if ((newValue as? String).isNullOrEmpty()) "" else "******"
} else {
p.summary = (newValue as? String).orEmpty()
}
true
}
}
@@ -159,14 +167,16 @@ 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))
}
private fun updateMode(value: String?) {
@@ -203,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
@@ -258,9 +245,45 @@ class SettingsActivity : BaseActivity() {
fragmentInterval?.isEnabled = enabled
}
private fun updateDynamicSocksPort(enabled: Boolean) {
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)
}
}
@@ -4,17 +4,22 @@ import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.ImageButton
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivitySubEditBinding
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.entities.SubscriptionItem
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.handler.SubscriptionUpdater
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -32,6 +37,7 @@ class SubEditActivity : BaseActivity() {
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
setupProfileRemarkInputs()
SettingsChangeManager.makeSetupGroupTab()
val subItem = MmkvManager.decodeSubscription(editSubId)
if (subItem != null) {
@@ -51,6 +57,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,11 +72,43 @@ 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
}
private fun setupProfileRemarkInputs() {
val suggestions = SettingsManager.getProfileRemarks(
excludeConfigTypes = setOf(
EConfigType.CUSTOM,
EConfigType.POLICYGROUP,
EConfigType.PROXYCHAIN,
)
)
setupProfileRemarkInput(binding.etPreProfile, binding.btnPreProfileDropdown, suggestions)
setupProfileRemarkInput(binding.etNextProfile, binding.btnNextProfileDropdown, suggestions)
}
private fun setupProfileRemarkInput(
input: AutoCompleteTextView,
dropdownButton: ImageButton,
suggestions: List<String>
) {
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
input.setAdapter(adapter)
input.threshold = 0
dropdownButton.setOnClickListener {
input.requestFocus()
input.showDropDown()
}
input.setOnClickListener {
input.showDropDown()
}
}
/**
* save server config
*/
@@ -82,6 +121,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 +165,7 @@ class SubEditActivity : BaseActivity() {
}
MmkvManager.encodeSubscription(editSubId, subItem)
SubscriptionUpdater.syncOne(subId = editSubId)
toastSuccess(R.string.toast_success)
finish()
return true

Some files were not shown because too many files have changed in this diff Show More