Compare commits

..

153 Commits

Author SHA1 Message Date
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
2dust 8dd1df1451 up 2.0.16 2026-03-23 20:52:45 +08:00
2dust d4a73493b5 Update AndroidLibXrayLite 2026-03-23 20:51:04 +08:00
2dust 9fce03d0ed Increase swipe refresh trigger distance to 160dp
https://github.com/2dust/v2rayNG/issues/5367
2026-03-21 14:38:34 +08:00
owo a12ca3ea04 Feature/regex and protocol search (#5364)
* add windows compile options

* add regex search and search from protocol function(#5354)

* Delete compile-hevtun.bat

* Delete fix_headers.bat

* Extract matchesPattern to extension

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-03-21 14:22:10 +08:00
solokot 4e9df84d3e Update Russian translation (#5374) 2026-03-21 13:56:58 +08:00
2dust d3118b53f2 up 2.0.15 2026-03-13 17:08:02 +08:00
2dust cd45b51487 Add locate FAB and scroll-to-selected server
https://github.com/2dust/v2rayNG/issues/5165
2026-03-13 15:02:08 +08:00
2dust 6aa26d2621 Fix
https://github.com/2dust/v2rayNG/issues/5361
2026-03-13 10:49:30 +08:00
2dust 0b452707c3 Pull down to update subscription 2026-03-12 19:23:52 +08:00
2dust 303b642ade Refactor subscription import & matching logic 2026-03-12 18:03:21 +08:00
2dust 80eec036dd up 2.0.14 2026-03-10 19:22:50 +08:00
2dust f9a85366ea Update libs.versions.toml 2026-03-10 17:55:48 +08:00
2dust 378c891399 Update submodule commit SHAs 2026-03-09 20:37:30 +08:00
2dust d04b5eee37 Add detailed logging and validations to services 2026-03-09 20:11:53 +08:00
2dust 405cd7f55e Ensure default subscription on removals
Add removeSubscriptionWithDefault in SettingsManager to remove a subscription and recreate a default subscription if none remain. Modify MmkvManager.decodeAllServerList to include servers from DEFAULT_SUBSCRIPTION_ID when it's not listed, and remove the defensive check in MmkvManager.removeSubscription so removals are delegated to SettingsManager. Update callers (SubEditActivity, SubscriptionsViewModel) to use SettingsManager.removeSubscriptionWithDefault, remove UI restrictions hiding the delete action for the default subscription, and simplify group-display logic in MainViewModel. These changes ensure a default subscription always exists and server lists include default servers when appropriate.
2026-03-09 19:38:29 +08:00
2dust 850096789b When geoip:cn and geoip:private appear in the routing rules, load geoip-only-cn-private.dat to reduce memory usage. 2026-03-09 16:52:57 +08:00
2dust 3d42ac9dba Add logging and safety checks in boot and VPN service
https://github.com/2dust/v2rayNG/issues/5346
2026-03-08 16:11:33 +08:00
2dust d0265265f3 up 2.0.13 2026-03-03 16:11:10 +08:00
2dust 2d6dd33a7b Fix real ping test
https://github.com/2dust/v2rayNG/issues/5331
2026-03-01 17:19:42 +08:00
2dust 3a32373abe up 2.0.12 2026-02-28 15:10:40 +08:00
Hossein Abaspanah befb2937d1 Update Luri Bakhtiari translation (#5330) 2026-02-28 13:59:14 +08:00
solokot 0a375bec67 Update Russian translation (#5328) 2026-02-28 13:59:02 +08:00
2dust 029f8b67e7 Remove legacy profileStorage and migration code 2026-02-28 11:34:03 +08:00
2dust 7475d1757b Add detailed subscription update result 2026-02-27 17:44:40 +08:00
dependabot[bot] 5a16614a7b Bump actions/upload-artifact from 6 to 7 (#5324)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  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-02-27 14:18:55 +08:00
solokot edb06b466a Update Russian translation (#5315) 2026-02-26 14:56:53 +08:00
Hossein Abaspanah 30b5391895 Update strings.xml (#5312) 2026-02-26 14:56:41 +08:00
2dust 6cd04b831f Improve search
https://github.com/2dust/v2rayNG/issues/5313
2026-02-24 16:55:16 +08:00
2dust 1e616198f0 up 2.0.11 2026-02-24 12:31:29 +08:00
2dust a710146e4f Use subscriptionId to fetch servers for real ping testing 2026-02-24 12:30:08 +08:00
2dust 9cd530d223 Batch parse and save subscription configs 2026-02-24 12:15:03 +08:00
2dust fc89f25591 Add 'Show All Groups' preference and handling 2026-02-24 10:38:28 +08:00
2dust 84ab54fea2 Migrate server storage to subscription lists
Introduce subscription-based server storage and migration from legacy KEY_ANG_CONFIGS. Added DEFAULT_SUBSCRIPTION_ID and new MmkvManager APIs to encode/decode server lists per subscription, decodeAllServerList, and readLegacyServerList; included helper getSubscriptionId and protection against deleting the default subscription. Refactored SettingsManager into initApp to ensure defaults, create a default subscription, initialize routing rulesets and perform server-list migration and other migrations. Updated callers (V2rayConfigManager, BackupActivity, GroupServerFragment, SubEditActivity, SubSettingRecyclerAdapter, TaskerActivity, MainViewModel) to use the new APIs and to handle subscriptionId where appropriate (including UI changes to hide delete for default subscription). Preserved legacy logic as commented references and added migration markers to avoid repeated migrations. Overall change enables grouping servers by subscription and provides a migration path for existing installations.
2026-02-23 20:58:01 +08:00
2dust d642d09844 Revert "Migrate servers to subscription-based storage"
This reverts commit 09461663cb.
2026-02-23 19:59:43 +08:00
2dust 6ae46c50e2 Revert "Bug fix for sort"
This reverts commit fe4b451988.
2026-02-23 19:59:29 +08:00
2dust fe4b451988 Bug fix for sort 2026-02-23 16:06:20 +08:00
AmirMohammad Yazdanmanesh 5c9b634039 Improve accessibility: add contentDescription to clickable action containers (#5286)
- Add contentDescription to action button containers (share, edit,
  delete) in subscription, routing, and user asset recycler items
- Add contentDescription to bypass mode info button and connection
  test area in main activity
- Mark 4 decorative icons in backup screen as not important for
  accessibility
2026-02-23 15:07:22 +08:00
2dust 09461663cb Migrate servers to subscription-based storage
Move server list storage into SubscriptionItem.serverList and add a default subscription for ungrouped servers. Introduce AppConfig.DEFAULT_SUBSCRIPTION_ID and update MmkvManager to read legacy data, encode/decode server lists by subscription, decodeAllServerList(), and protect default subscription from deletion. Add one-time migration logic and ensureDefaultSubscription() in SettingsManager, invoke migration during app startup in AngApplication, and update call sites (MainViewModel, V2rayConfigManager, TaskerActivity, etc.) to use the new subscription-aware APIs. Preserve legacy code paths as comments and mark migration completion with a settings flag.
2026-02-23 14:41:53 +08:00
2dust 50c1145815 Organize imports across Kotlin files 2026-02-23 10:49:50 +08:00
2dust edc4856cb8 up 2.0.10 2026-02-22 14:21:20 +08:00
2dust 4dd7eee8fe Throttle notification speed queries
https://github.com/2dust/v2rayNG/issues/5267
2026-02-22 12:54:19 +08:00
2dust 516637954c Use theme primary color for help link text
https://github.com/2dust/v2rayNG/issues/5273
2026-02-22 12:12:46 +08:00
2dust 9e714bfcb3 Disable mux for HYSTERIA
https://github.com/2dust/v2rayNG/issues/5282
2026-02-22 12:08:13 +08:00
2dust 858dc5237a Update AGP to 9.0.1 & Gradle to 9.3.1 2026-02-22 11:32:17 +08:00
2dust 2b4a4f7ecc Increase Toasty bottom offset to 300
https://github.com/2dust/v2rayNG/issues/5306
2026-02-22 11:31:04 +08:00
2dust f9f0c905a4 up 2.0.9 2026-02-07 11:06:16 +08:00
2dust f997335113 Update libs.versions.toml 2026-02-07 11:05:47 +08:00
AmirMohammad Yazdanmanesh ed344cd281 Improve accessibility: mark decorative icons across remaining layouts (#5265)
Add importantForAccessibility="no" to decorative ImageViews in check
update, bypass list, routing, subscription, user asset, and widget
layouts. Add contentDescription to QR code ImageView.
2026-02-07 10:46:10 +08:00
2dust 94775f120a Migrate Hysteria2 pinSHA256 to pinnedCA256
Rename and migrate Hysteria2 certificate pin field from pinSHA256 to pinnedCA256 across code and UI, and add a one-time migration at startup.

Changes:
- Added SettingsManager.migrateHysteria2PinSHA256() and call in AngApplication to copy existing profile.pinSHA256 -> profile.pinnedCA256 and clear the old field, guarded by a migration flag.
- Updated Hysteria2Fmt to read/write the query param "pinSHA256" into the new ProfileItem.pinnedCA256 field.
- Updated ProfileItem equality to compare pinnedCA256 instead of pinSHA256.
- Removed the old EditText binding and reads/writes for et_pinsha256 in ServerActivity; UI now uses et_pinned_ca256 (layout id renamed).
- Updated layout and string resources: renamed the label/id to reflect pinned_ca256 and removed legacy pinSHA256 string entries in several locale files.

This ensures existing Hysteria2 profiles keep their pin data while the code and UI use the new field name.
2026-02-07 10:42:19 +08:00
2dust abd23b3227 up 2.0.8 2026-02-06 20:08:17 +08:00
2dust 7d3e002140 Update hev-socks5-tunnel 2026-02-06 19:45:41 +08:00
2dust 1adb22944a Update AndroidLibXrayLite 2026-02-06 19:45:34 +08:00
DHR60 f48253371d Follow encodeURIComponent rules strictly (#5250) 2026-02-06 19:38:45 +08:00
AmirMohammad Yazdanmanesh 74f701096e Improve accessibility: mark decorative icons as not important for accessibility (#5247) 2026-02-06 19:37:25 +08:00
DHR60 8eca5fe8af Add finalmask support (#5242) 2026-02-06 19:27:02 +08:00
solokot ff40b53e2f Update Russian translation (#5238) 2026-02-01 18:14:38 +08:00
2dust 5080d94bb7 Add description field to ProfileItem and generate descriptions 2026-01-30 17:24:13 +08:00
2dust 24539be29f up 2.0.7 2026-01-29 17:18:05 +08:00
2dust e951e3d913 Bug fix for tasker plugin 2026-01-29 17:07:42 +08:00
2dust 83e5f1d10a Refactor VPN service stop logic and improve error handling 2026-01-29 16:54:01 +08:00
2dust 47a6623e62 Refactor: move interfaces to contracts package 2026-01-29 15:21:36 +08:00
2dust d6e3fc5c91 Move enums from dto to enums package and update imports 2026-01-29 14:51:59 +08:00
2dust 685d23f2d1 Refactor asset URL handling with AssetUrlCache data class 2026-01-29 14:43:31 +08:00
2dust d46bbdc219 Refactor duplicate server removal logic 2026-01-29 14:34:15 +08:00
2dust be95e6a88e Refactor subscription handling with SubscriptionCache 2026-01-29 14:28:34 +08:00
2dust 2435708628 Refactor to use nullIfBlank 2026-01-29 11:26:48 +08:00
2dust 6cf6057dfa Add support for pinned certificate SHA-256 fingerprints 2026-01-29 11:00:19 +08:00
2dust af92cf5a7d Refactor activity helpers into HelperBaseActivity 2026-01-29 10:12:19 +08:00
2dust 2277de95c0 Add support for file creation using Storage Access Framework 2026-01-28 19:39:42 +08:00
2dust 3c90ee0206 Refactor QR code and file chooser handling in activities 2026-01-28 19:29:31 +08:00
2dust 41fd237a87 Refactor file and permission helpers, update usage 2026-01-28 19:13:34 +08:00
2dust f6bded88dd Refactor file chooser logic into reusable helper 2026-01-28 17:07:57 +08:00
2dust 8600fdba35 Refactor permission handling with PermissionRequester 2026-01-28 16:18:19 +08:00
2dust 2c47abc219 Update dependabot.yml to retain only github-actions ecosystem 2026-01-27 10:02:14 +08:00
Pádraic Slattery 10b0f8c9de chore: Update outdated GitHub Actions versions (#5208) 2026-01-27 09:59:15 +08:00
2dust 53572877fa Add Dependabot configuration 2026-01-27 09:55:35 +08:00
2dust 69e000b23d Add Random and Round Robin policy group types 2026-01-26 20:11:30 +08:00
Hossein Abaspanah a8e09611eb Update strings.xml (#5203) 2026-01-26 08:52:12 +08:00
2dust 4fdc46b8ca Update AGP version and add Gradle build properties
Upgraded Android Gradle Plugin version from 8.13.2 to 9.0.0 in libs.versions.toml. Added and updated several Android and Kotlin build properties in gradle.properties to refine build behavior and feature flags.
2026-01-24 14:16:39 +08:00
2dust ac043da8a2 up 2.0.6 2026-01-24 10:20:18 +08:00
2dust a192fec127 Update AndroidLibXrayLite 2026-01-24 10:19:46 +08:00
2dust b0b09273cf Restrict scanner and decoder to QR codes only 2026-01-23 19:41:21 +08:00
2dust 01a943b7ea Refactor main list adapter with listener interface 2026-01-23 17:09:35 +08:00
2dust 06a3738b59 Remove activity parameter from PerAppProxyAdapter 2026-01-22 18:34:05 +08:00
2dust fa4432584e up 2.0.5 2026-01-21 17:44:03 +08:00
2dust bb1c3d915f Refactor user asset management with ViewModel and adapter 2026-01-21 17:43:34 +08:00
2dust d8983a4761 Bug fix
https://github.com/2dust/v2rayNG/issues/5186
2026-01-20 21:07:51 +08:00
2dust 9815041c77 Remove unused imports and commented debug logs 2026-01-20 10:52:52 +08:00
2dust 41b0983582 Refactor logcat handling to use LogcatViewModel 2026-01-20 10:46:46 +08:00
2dust bb1f677d45 Refactor RoutingSetting adapter to use listener interface 2026-01-20 10:17:57 +08:00
2dust de1c8bcfcc Refactor VPN mode checks and TUN config logic
Centralizes VPN mode detection in SettingsManager with a new isVpnMode() method and updates all usages to reference it. Refactors TUN configuration logic in V2rayConfigManager for clarity and correctness, ensuring proper config and routing rules are applied based on VPN and HEV TUN settings.
2026-01-19 19:43:09 +08:00
2dust ca6e963575 Refactor remarks assignment using ifEmpty in formatters 2026-01-19 19:18:26 +08:00
2dust bfb6813dc7 Fix HevTun settings to preferences UI 2026-01-19 19:18:15 +08:00
2dust cc2b4eec65 up 2.0.4 2026-01-18 15:23:02 +08:00
2dust dd11569969 Add version field to Hysteria2 config 2026-01-18 15:22:33 +08:00
2dust 11c3dd97c3 Update AndroidLibXrayLite 2026-01-18 15:00:07 +08:00
2dust 92f15150cd up 2.0.3 2026-01-18 14:58:40 +08:00
2dust f8e809da8b Refactor adapter callbacks with BaseAdapterListener 2026-01-18 14:58:15 +08:00
DHR60 12b9349e62 Fix sniffing (#5173)
* Fix sniffing

* Fix custom config
2026-01-16 19:52:18 +08:00
DHR60 a580494bde Update sdk (#5174) 2026-01-16 19:22:43 +08:00
2dust 66f100ebb3 up 2.0.2 2026-01-16 17:40:34 +08:00
2dust b0213ffdf6 Refactor FAB state handling in MainActivity 2026-01-16 17:40:09 +08:00
2dust 538b2eb0f8 Bug fix
https://github.com/2dust/v2rayNG/issues/5172
2026-01-16 16:36:01 +08:00
2dust f2093c4c52 Refactor real ping test logic into worker service 2026-01-16 16:28:35 +08:00
2dust 02ac57a4c6 Fix and improve port hopping interval handling
https://github.com/2dust/v2rayNG/issues/5171
2026-01-16 15:21:19 +08:00
2dust 379266f205 Add tun inbound to custom config if missing
Updated getV2rayCustomConfig to check for the presence of a 'tun' inbound in the configuration. If not present and HevTun is not used, the method adds a 'tun' inbound from the template configuration and updates the config accordingly.
2026-01-16 15:07:10 +08:00
2dust 23dbb35da7 Refactor MainActivity and clean up SettingsActivity 2026-01-15 17:54:15 +08:00
2dust 22a605ecda up 2.0.1 2026-01-15 14:51:11 +08:00
2dust c7dd45f4f2 Bug fix
https://github.com/2dust/v2rayNG/issues/5164
2026-01-15 14:49:29 +08:00
2dust 609e688d87 Fix HYSTERIA2 string 2026-01-15 14:30:10 +08:00
2dust a2a4ea79a9 Optimize coroutine dispatcher in V2RayTestService 2026-01-15 14:22:24 +08:00
2dust c08147c362 Refactor WebDAV backup path and file name handling 2026-01-15 11:52:18 +08:00
2dust c99fda9839 Bug fix
https://github.com/2dust/v2rayNG/issues/5158
2026-01-15 11:11:16 +08:00
2dust 587d103518 Fix enum declaration for RoutingType 2026-01-15 10:41:06 +08:00
Umor1st 192a4afe6d Add Russia whitelist routing option (#5161)
* Add Russia whitelist routing option

* Update RoutingType.kt

* Add custom routing file in assets

* Update localization
2026-01-15 10:34:11 +08:00
2dust ee69364f2b Remove hysteria submodule and related build steps 2026-01-15 10:32:10 +08:00
2dust 6be0bd7b21 Remove plugin framework and refactor Hysteria2 integration 2026-01-15 10:19:25 +08:00
158 changed files with 4373 additions and 3150 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+8 -38
View File
@@ -16,17 +16,17 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v6
with: with:
submodules: 'recursive' submodules: 'recursive'
fetch-depth: '0' fetch-depth: '0'
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3.2.0 uses: android-actions/setup-android@v4.0.1
with: with:
log-accepted-android-sdk-licenses: false log-accepted-android-sdk-licenses: false
cmdline-tools-version: '12266719' cmdline-tools-version: '13114758'
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools' packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
- name: Install NDK - name: Install NDK
run: | run: |
@@ -77,38 +77,8 @@ jobs:
fileName: 'libv2ray.aar' fileName: 'libv2ray.aar'
out-file-path: V2rayNG/app/libs/ out-file-path: V2rayNG/app/libs/
- name: Restore cached libhysteria2
id: cache-libhysteria2-restore
uses: actions/cache/restore@v4
with:
path: ${{ github.workspace }}/hysteria/libs
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
- name: Setup Golang
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/setup-go@v5.4.0
with:
go-version-file: 'AndroidLibXrayLite/go.mod'
cache: false
- name: Build libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
run: |
bash libhysteria2.sh
- name: Save libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ${{ github.workspace }}/hysteria/libs
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
- name: Copy libhysteria2
run: |
cp -r ${{ github.workspace }}/hysteria/libs ${{ github.workspace }}/V2rayNG/app
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4.7.0 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -129,21 +99,21 @@ jobs:
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }} ./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
- name: Upload arm64-v8a APK - name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v7
if: ${{ success() }} if: ${{ success() }}
with: with:
name: arm64-v8a name: arm64-v8a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
- name: Upload armeabi-v7a APK - name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v7
if: ${{ success() }} if: ${{ success() }}
with: with:
name: armeabi-v7a name: armeabi-v7a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
- name: Upload x86 APK - name: Upload x86 APK
uses: actions/upload-artifact@v4.6.2 uses: actions/upload-artifact@v7
if: ${{ success() }} if: ${{ success() }}
with: with:
name: x86-apk name: x86-apk
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
go: go:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Validate Fastlane Supply Metadata - name: Validate Fastlane Supply Metadata
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0 uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
-3
View File
@@ -1,6 +1,3 @@
[submodule "hysteria"]
path = hysteria
url = https://github.com/apernet/hysteria
[submodule "AndroidLibXrayLite"] [submodule "AndroidLibXrayLite"]
path = AndroidLibXrayLite path = AndroidLibXrayLite
url = https://github.com/2dust/AndroidLibXrayLite url = https://github.com/2dust/AndroidLibXrayLite
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang" applicationId = "com.v2ray.ang"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 700 versionCode = 720
versionName = "2.0.0" versionName = "2.1.0"
multiDexEnabled = true multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -30,4 +30,34 @@
android:targetPackage="com.v2ray.ang.fdroid" /> android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" /> <categories android:name="android.shortcut.conversation" />
</shortcut> </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> </shortcuts>
+12
View File
@@ -122,6 +122,18 @@
android:exported="false" android:exported="false"
android:process=":RunSoLibV2RayDaemon" android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" /> 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 <activity
android:name=".ui.UrlSchemeActivity" android:name=".ui.UrlSchemeActivity"
@@ -0,0 +1,43 @@
[
{
"remarks": "Bypass bittorrent",
"outboundTag": "direct",
"protocol": [
"bittorrent"
]
},
{
"remarks": "Block udp443",
"outboundTag": "block",
"port": "443",
"network": "udp"
},
{
"remarks": "Direct LAN IP",
"outboundTag": "direct",
"ip": [
"geoip:private"
]
},
{
"remarks": "Direct LAN domains",
"outboundTag": "direct",
"domain": [
"geosite:private"
]
},
{
"remarks": "Bypass Russia domains",
"outboundTag": "direct",
"domain": [
"geosite:category-ru"
]
},
{
"remarks": "Bypass Russia IP",
"outboundTag": "direct",
"ip": [
"geoip:ru"
]
}
]
@@ -36,12 +36,18 @@
}, },
{ {
"tag": "tun", "tag": "tun",
"port": 0,
"protocol": "tun", "protocol": "tun",
"settings": { "settings": {
"name": "xray0", "name": "xray0",
"MTU": 1500, "MTU": 1500,
"userLevel": 8 "userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
} }
} }
], ],
@@ -34,16 +34,15 @@ class AngApplication : MultiDexApplication() {
MMKV.initialize(this) MMKV.initialize(this)
// Ensure critical preference defaults are present in MMKV early
SettingsManager.ensureDefaultSettings()
SettingsManager.setNightMode()
// Initialize WorkManager with the custom configuration // Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration) WorkManager.initialize(this, workManagerConfiguration)
SettingsManager.initRoutingRulesets(this) // Ensure critical preference defaults are present in MMKV early
SettingsManager.initApp(this)
SettingsManager.setNightMode()
es.dmoral.toasty.Toasty.Config.getInstance() es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 200) .setGravity(android.view.Gravity.BOTTOM, 0, 300)
.apply() .apply()
} }
} }
@@ -9,11 +9,16 @@ object AppConfig {
/** Directory names used in the app's file system. */ /** Directory names used in the app's file system. */
const val DIR_ASSETS = "assets" const val DIR_ASSETS = "assets"
const val DIR_BACKUPS = "backups"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
/** Legacy configuration keys. */ /** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config" const val ANG_CONFIG = "ang_config"
// Default subscription ID for ungrouped servers
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
/** Preferences mapped to MMKV storage. */ /** Preferences mapped to MMKV storage. */
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled" const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled" const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
@@ -46,12 +51,17 @@ object AppConfig {
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove" const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate" const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display" const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
const val PREF_LANGUAGE = "pref_language" const val PREF_LANGUAGE = "pref_language"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night" 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_PREFER_IPV6 = "pref_prefer_ipv6"
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled" const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
const val PREF_ALLOW_INSECURE = "pref_allow_insecure" const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
const val PREF_SOCKS_PORT = "pref_socks_port" 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_REMOTE_DNS = "pref_remote_dns" const val PREF_REMOTE_DNS = "pref_remote_dns"
const val PREF_DOMESTIC_DNS = "pref_domestic_dns" const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
const val PREF_DNS_HOSTS = "pref_dns_hosts" const val PREF_DNS_HOSTS = "pref_dns_hosts"
@@ -71,7 +81,6 @@ object AppConfig {
/** Cache keys. */ /** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id" const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
/** Protocol identifiers. */ /** Protocol identifiers. */
const val PROTOCOL_FREEDOM = "freedom" const val PROTOCOL_FREEDOM = "freedom"
@@ -95,6 +104,7 @@ object AppConfig {
const val TAG_FRAGMENT = "fragment" const val TAG_FRAGMENT = "fragment"
const val TAG_DNS = "dns-module" const val TAG_DNS = "dns-module"
const val TAG_DOMESTIC_DNS = "domestic-dns" const val TAG_DOMESTIC_DNS = "domestic-dns"
const val TAG_BALANCER = "proxy-round"
/** Network-related constants. */ /** Network-related constants. */
const val UPLINK = "uplink" const val UPLINK = "uplink"
@@ -114,7 +124,8 @@ object AppConfig {
const val TG_CHANNEL_URL = "https://t.me/github_2dust" 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_URL = "https://www.gstatic.com/generate_204"
const val DELAY_TEST_URL2 = "https://www.google.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" const val IP_API_URL = "https://api.ip.sb/geoip"
/** DNS server addresses. */ /** DNS server addresses. */
@@ -126,6 +137,12 @@ object AppConfig {
const val GEOIP_PRIVATE = "geoip:private" const val GEOIP_PRIVATE = "geoip:private"
const val GEOIP_CN = "geoip:cn" const val GEOIP_CN = "geoip:cn"
/** Geo data file names. */
const val GEOSITE_DAT = "geosite.dat"
const val GEOIP_DAT = "geoip.dat"
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
/** Ports and addresses for various services. */ /** Ports and addresses for various services. */
const val PORT_LOCAL_DNS = "10853" const val PORT_LOCAL_DNS = "10853"
const val PORT_SOCKS = "10808" const val PORT_SOCKS = "10808"
@@ -169,6 +186,7 @@ object AppConfig {
const val TROJAN = "trojan://" const val TROJAN = "trojan://"
const val WIREGUARD = "wireguard://" const val WIREGUARD = "wireguard://"
const val TUIC = "tuic://" const val TUIC = "tuic://"
const val HYSTERIA = "hysteria://"
const val HYSTERIA2 = "hysteria2://" const val HYSTERIA2 = "hysteria2://"
const val HY2 = "hy2://" const val HY2 = "hy2://"
@@ -261,4 +279,9 @@ object AppConfig {
"Chocolate4U/Iran-v2ray-rules" "Chocolate4U/Iran-v2ray-rules"
) )
val BUILTIN_OUTBOUND_TAGS = setOf(
TAG_PROXY,
TAG_DIRECT,
TAG_BLOCKED,
)
} }
@@ -0,0 +1,32 @@
package com.v2ray.ang.contracts
/**
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
* Extend this interface or define more specific interfaces for different adapters as needed.
*/
interface BaseAdapterListener {
/**
* Request the host to edit the specified item.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onEdit(guid: String, position: Int)
/**
* Request the host to remove the specified item. Position is provided for optional animation or validation.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onRemove(guid: String, position: Int)
/**
* Request the host to share the specified URL.
* @param url The URL to be shared
*/
fun onShare(url: String)
/**
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
*/
fun onRefreshData()
}
@@ -0,0 +1,13 @@
package com.v2ray.ang.contracts
import com.v2ray.ang.dto.ProfileItem
interface MainAdapterListener : BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
fun onSelectServer(guid: String)
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.service package com.v2ray.ang.contracts
import android.app.Service import android.app.Service
@@ -1,4 +1,4 @@
package com.v2ray.ang.service package com.v2ray.ang.contracts
/** /**
* Interface that defines the control operations for tun2socks implementations. * Interface that defines the control operations for tun2socks implementations.
@@ -11,7 +11,7 @@ interface Tun2SocksControl {
* This initializes the VPN tunnel and connects it to the SOCKS proxy. * This initializes the VPN tunnel and connects it to the SOCKS proxy.
*/ */
fun startTun2Socks() fun startTun2Socks()
/** /**
* Stops the tun2socks process and cleans up resources. * Stops the tun2socks process and cleans up resources.
*/ */
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class AssetUrlCache(
val guid: String,
val assetUrl: AssetUrlItem
)
@@ -4,6 +4,5 @@ data class ConfigResult(
var status: Boolean, var status: Boolean,
var guid: String? = null, var guid: String? = null,
var content: String = "", var content: String = "",
var socksPort: Int? = null,
) )
@@ -1,46 +0,0 @@
package com.v2ray.ang.dto
data class Hysteria2Bean(
val server: String?,
val auth: String?,
val lazy: Boolean? = true,
val obfs: ObfsBean? = null,
val socks5: Socks5Bean? = null,
val http: Socks5Bean? = null,
val tls: TlsBean? = null,
val transport: TransportBean? = null,
val bandwidth: BandwidthBean? = null,
) {
data class ObfsBean(
val type: String?,
val salamander: SalamanderBean?
) {
data class SalamanderBean(
val password: String?,
)
}
data class Socks5Bean(
val listen: String?,
)
data class TlsBean(
val sni: String?,
val insecure: Boolean?,
val pinSHA256: String?,
)
data class TransportBean(
val type: String?,
val udp: TransportUdpBean?
) {
data class TransportUdpBean(
val hopInterval: String?,
)
}
data class BandwidthBean(
val down: String?,
val up: String?,
)
}
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig.PORT_SOCKS
import com.v2ray.ang.AppConfig.TAG_BLOCKED import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
data class ProfileItem( data class ProfileItem(
@@ -14,6 +15,7 @@ data class ProfileItem(
var addedTime: Long = System.currentTimeMillis(), var addedTime: Long = System.currentTimeMillis(),
var remarks: String = "", var remarks: String = "",
var description: String? = null,
var server: String? = null, var server: String? = null,
var serverPort: String? = null, var serverPort: String? = null,
@@ -34,7 +36,7 @@ data class ProfileItem(
var authority: String? = null, var authority: String? = null,
var xhttpMode: String? = null, var xhttpMode: String? = null,
var xhttpExtra: String? = null, var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null, var security: String? = null,
var sni: String? = null, var sni: String? = null,
var alpn: String? = null, var alpn: String? = null,
@@ -42,6 +44,7 @@ data class ProfileItem(
var insecure: Boolean? = null, var insecure: Boolean? = null,
var echConfigList: String? = null, var echConfigList: String? = null,
var echForceQuery: String? = null, var echForceQuery: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null, var publicKey: String? = null,
var shortId: String? = null, var shortId: String? = null,
@@ -121,7 +124,7 @@ data class ProfileItem(
&& this.obfsPassword == obj.obfsPassword && this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping && this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval && this.portHoppingInterval == obj.portHoppingInterval
&& this.pinSHA256 == obj.pinSHA256 && this.pinnedCA256 == obj.pinnedCA256
) )
} }
} }
@@ -4,6 +4,7 @@ data class RulesetItem(
var remarks: String? = "", var remarks: String? = "",
var ip: List<String>? = null, var ip: List<String>? = null,
var domain: List<String>? = null, var domain: List<String>? = null,
var process: List<String>? = null,
var outboundTag: String = "", var outboundTag: String = "",
var port: String? = null, var port: String? = null,
var network: String? = null, var network: String? = null,
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class SubscriptionCache(
val guid: String,
val subscription: SubscriptionItem
)
@@ -0,0 +1,24 @@
package com.v2ray.ang.dto
/**
* Result of subscription update operation
*/
data class SubscriptionUpdateResult(
val configCount: Int = 0, // Total configs updated
val successCount: Int = 0, // Subscriptions updated successfully
val failureCount: Int = 0, // Subscriptions failed to update
val skipCount: Int = 0 // Subscriptions skipped (disabled)
) {
/**
* Combine two results by adding their counts
*/
operator fun plus(other: SubscriptionUpdateResult): SubscriptionUpdateResult {
return SubscriptionUpdateResult(
configCount = this.configCount + other.configCount,
successCount = this.successCount + other.successCount,
failureCount = this.failureCount + other.failureCount,
skipCount = this.skipCount + other.skipCount
)
}
}
@@ -0,0 +1,10 @@
package com.v2ray.ang.dto
import java.io.Serializable
data class TestServiceMessage(
val key: Int,
val subscriptionId: String = "",
val serverGuids: List<String> = emptyList()
) : Serializable
@@ -2,7 +2,7 @@ package com.v2ray.ang.dto
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.Utils import com.v2ray.ang.enums.EConfigType
data class V2rayConfig( data class V2rayConfig(
var remarks: String? = null, var remarks: String? = null,
@@ -31,11 +31,11 @@ data class V2rayConfig(
data class InboundBean( data class InboundBean(
var tag: String, var tag: String,
var port: Int, var port: Int?,
var protocol: String, var protocol: String,
var listen: String? = null, var listen: String? = null,
var settings: InSettingsBean? = null, var settings: InSettingsBean? = null,
val sniffing: SniffingBean? = null, var sniffing: SniffingBean? = null,
val streamSettings: Any? = null, val streamSettings: Any? = null,
val allocate: Any? = null val allocate: Any? = null
) { ) {
@@ -44,10 +44,16 @@ data class V2rayConfig(
var auth: String? = null, var auth: String? = null,
var udp: Boolean? = null, var udp: Boolean? = null,
var userLevel: Int? = null, var userLevel: Int? = null,
var accounts: List<SocksAccountBean>? = null,
var name: String? = null, var name: String? = null,
@SerializedName("MTU")
var mtu: Int? = null var mtu: Int? = null
) ) {
data class SocksAccountBean(
var user: String = "",
var pass: String = ""
)
}
data class SniffingBean( data class SniffingBean(
var enabled: Boolean, var enabled: Boolean,
@@ -68,15 +74,13 @@ data class V2rayConfig(
) { ) {
data class OutSettingsBean( data class OutSettingsBean(
var vnext: List<VnextBean>? = null, var vnext: List<VnextBean>? = null,
var fragment: FragmentBean? = null,
var noises: List<NoiseBean>? = null,
var servers: List<ServersBean>? = null, var servers: List<ServersBean>? = null,
/*Blackhole*/ /*Blackhole*/
var response: Response? = null, var response: Response? = null,
/*DNS*/ /*DNS*/
val network: String? = null, val network: String? = null,
var address: Any? = null, var address: Any? = null,
val port: Int? = null, var port: Int? = null,
/*Freedom*/ /*Freedom*/
var domainStrategy: String? = null, var domainStrategy: String? = null,
val redirect: String? = null, val redirect: String? = null,
@@ -89,6 +93,7 @@ data class V2rayConfig(
var reserved: List<Int>? = null, var reserved: List<Int>? = null,
var mtu: Int? = null, var mtu: Int? = null,
var obfsPassword: String? = null, var obfsPassword: String? = null,
var version: Int? = null,
) { ) {
data class VnextBean( data class VnextBean(
@@ -107,18 +112,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( data class ServersBean(
var address: String = "", var address: String = "",
var method: String? = null, var method: String? = null,
@@ -160,7 +153,8 @@ data class V2rayConfig(
var quicSettings: QuicSettingBean? = null, var quicSettings: QuicSettingBean? = null,
var realitySettings: TlsSettingsBean? = null, var realitySettings: TlsSettingsBean? = null,
var grpcSettings: GrpcSettingsBean? = null, var grpcSettings: GrpcSettingsBean? = null,
var hy2steriaSettings: Hy2steriaSettingsBean? = null, var hysteriaSettings: HysteriaSettingsBean? = null,
var finalmask: Any? = null,
val dsSettings: Any? = null, val dsSettings: Any? = null,
var sockopt: SockoptBean? = null var sockopt: SockoptBean? = null
) { ) {
@@ -200,15 +194,8 @@ data class V2rayConfig(
var downlinkCapacity: Int = 100, var downlinkCapacity: Int = 100,
var congestion: Boolean = false, var congestion: Boolean = false,
var readBufferSize: Int = 1, var readBufferSize: Int = 1,
var writeBufferSize: Int = 1, var writeBufferSize: Int = 1
var header: HeaderBean = HeaderBean(), )
var seed: String? = null
) {
data class HeaderBean(
var type: String = "none",
var domain: String? = null
)
}
data class WsSettingsBean( data class WsSettingsBean(
var path: String? = null, var path: String? = null,
@@ -247,7 +234,8 @@ data class V2rayConfig(
var dialerProxy: String? = null, var dialerProxy: String? = null,
var domainStrategy: String? = null, var domainStrategy: String? = null,
var happyEyeballs: HappyEyeballsBean? = null, var happyEyeballs: HappyEyeballsBean? = null,
) )
data class HappyEyeballsBean( data class HappyEyeballsBean(
var prioritizeIPv6: Boolean? = null, var prioritizeIPv6: Boolean? = null,
var maxConcurrentTry: Int? = 4, var maxConcurrentTry: Int? = 4,
@@ -269,6 +257,7 @@ data class V2rayConfig(
val enableSessionResumption: Boolean? = null, val enableSessionResumption: Boolean? = null,
var echConfigList: String? = null, var echConfigList: String? = null,
var echForceQuery: String? = null, var echForceQuery: String? = null,
var pinnedPeerCertSha256: String? = null,
// REALITY settings // REALITY settings
val show: Boolean = false, val show: Boolean = false,
var publicKey: String? = null, var publicKey: String? = null,
@@ -293,18 +282,56 @@ data class V2rayConfig(
var health_check_timeout: Int? = null var health_check_timeout: Int? = null
) )
data class Hy2steriaSettingsBean( data class HysteriaSettingsBean(
var password: String? = null, var version: Int,
var use_udp_extension: Boolean? = true, var auth: String? = null
var congestion: Hy2CongestionBean? = null )
) {
data class Hy2CongestionBean(
var type: String? = "bbr",
var up_mbps: Int? = null,
var down_mbps: Int? = null,
)
}
//https://xtls.github.io/config/transport.html#finalmaskobject
data class FinalMaskBean(
var tcp: 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(
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
)
}
}
} }
data class MuxBean( data class MuxBean(
@@ -323,11 +350,14 @@ data class V2rayConfig(
|| protocol.equals(EConfigType.SOCKS.name, true) || protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true) || protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true) || protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) { ) {
return settings?.servers?.first()?.address return settings?.servers?.first()?.address
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringBeforeLast(":") return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.address as String?
} }
return null return null
} }
@@ -341,132 +371,14 @@ data class V2rayConfig(
|| protocol.equals(EConfigType.SOCKS.name, true) || protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true) || protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true) || protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) { ) {
return settings?.servers?.first()?.port return settings?.servers?.first()?.port
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt() return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
} } else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
return null || protocol.equals(EConfigType.HYSTERIA.name, true)
}
fun getServerAddressAndPort(): String {
val address = getServerAddress().orEmpty()
val port = getServerPort()
return Utils.getIpv6Address(address) + ":" + port
}
fun getPassword(): String? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) { ) {
return settings?.vnext?.first()?.users?.first()?.id return settings?.port
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) {
return settings?.servers?.first()?.password
} else if (protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
) {
return settings?.servers?.first()?.users?.first()?.pass
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.secretKey
}
return null
}
fun getSecurityEncryption(): String? {
return when {
protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.first()?.users?.first()?.security
protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.first()?.users?.first()?.encryption
protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.first()?.method
else -> null
}
}
fun getTransportSettingDetails(): List<String?>? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.SHADOWSOCKS.name, true)
) {
val transport = streamSettings?.network ?: return null
return when (transport) {
NetworkType.TCP.type -> {
val tcpSetting = streamSettings?.tcpSettings ?: return null
listOf(
tcpSetting.header.type,
tcpSetting.header.request?.headers?.Host?.joinToString(",").orEmpty(),
tcpSetting.header.request?.path?.joinToString(",").orEmpty()
)
}
NetworkType.KCP.type -> {
val kcpSetting = streamSettings?.kcpSettings ?: return null
listOf(
kcpSetting.header.type,
"",
kcpSetting.seed.orEmpty()
)
}
NetworkType.WS.type -> {
val wsSetting = streamSettings?.wsSettings ?: return null
listOf(
"",
wsSetting.headers.Host,
wsSetting.path
)
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = streamSettings?.httpupgradeSettings ?: return null
listOf(
"",
httpupgradeSetting.host,
httpupgradeSetting.path
)
}
NetworkType.XHTTP.type -> {
val xhttpSettings = streamSettings?.xhttpSettings ?: return null
listOf(
"",
xhttpSettings.host,
xhttpSettings.path
)
}
NetworkType.H2.type -> {
val h2Setting = streamSettings?.httpSettings ?: return null
listOf(
"",
h2Setting.host.joinToString(","),
h2Setting.path
)
}
// "quic" -> {
// val quicSetting = streamSettings?.quicSettings ?: return null
// listOf(
// quicSetting.header.type,
// quicSetting.security,
// quicSetting.key
// )
// }
NetworkType.GRPC.type -> {
val grpcSetting = streamSettings?.grpcSettings ?: return null
listOf(
if (grpcSetting.multiMode == true) "multi" else "gun",
grpcSetting.authority.orEmpty(),
grpcSetting.serviceName
)
}
else -> null
}
} }
return null return null
} }
@@ -512,8 +424,9 @@ data class V2rayConfig(
data class RulesBean( data class RulesBean(
var type: String = "field", var type: String = "field",
var ip: ArrayList<String>? = null, var ip: List<String>? = null,
var domain: ArrayList<String>? = null, var domain: List<String>? = null,
var process: List<String>? = null,
var outboundTag: String? = null, var outboundTag: String? = null,
var balancerTag: String? = null, var balancerTag: String? = null,
var port: String? = null, var port: String? = null,
@@ -1,8 +1,7 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
enum class EConfigType(val value: Int, val protocolScheme: String) { enum class EConfigType(val value: Int, val protocolScheme: String) {
VMESS(1, AppConfig.VMESS), VMESS(1, AppConfig.VMESS),
CUSTOM(2, AppConfig.CUSTOM), CUSTOM(2, AppConfig.CUSTOM),
@@ -14,10 +13,11 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
// TUIC(8, AppConfig.TUIC), // TUIC(8, AppConfig.TUIC),
HYSTERIA2(9, AppConfig.HYSTERIA2), HYSTERIA2(9, AppConfig.HYSTERIA2),
HYSTERIA(900, AppConfig.HYSTERIA),
HTTP(10, AppConfig.HTTP), HTTP(10, AppConfig.HTTP),
POLICYGROUP (101, AppConfig.CUSTOM); POLICYGROUP(101, AppConfig.CUSTOM);
companion object { companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
} }
} }
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class Language(val code: String) { enum class Language(val code: String) {
AUTO("auto"), AUTO("auto"),
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class NetworkType(val type: String) { enum class NetworkType(val type: String) {
TCP("tcp"), TCP("tcp"),
@@ -10,7 +10,8 @@ enum class NetworkType(val type: String) {
H2("h2"), H2("h2"),
//QUIC("quic"), //QUIC("quic"),
GRPC("grpc"); GRPC("grpc"),
HYSTERIA("hysteria");
companion object { companion object {
fun fromString(type: String?) = entries.find { it.type == type } ?: TCP fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
@@ -0,0 +1,44 @@
package com.v2ray.ang.enums
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
/**
* Permission types used in the app, handling API level differences.
*/
enum class PermissionType {
/** Camera permission (used for scanning QR codes) */
CAMERA {
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)
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
};
/** Return the actual Android permission string */
abstract fun getPermission(): String
/** Return a human-readable label for the permission */
fun getLabel(): String {
return when (this) {
CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification"
}
}
}
@@ -1,10 +1,11 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
enum class RoutingType(val fileName: String) { enum class RoutingType(val fileName: String) {
WHITE("custom_routing_white"), WHITE("custom_routing_white"),
BLACK("custom_routing_black"), BLACK("custom_routing_black"),
GLOBAL("custom_routing_global"), GLOBAL("custom_routing_global"),
WHITE_IRAN("custom_routing_white_iran"); WHITE_IRAN("custom_routing_white_iran"),
WHITE_RUSSIA("custom_routing_white_russia");
companion object { companion object {
fun fromIndex(index: Int): RoutingType { fun fromIndex(index: Int): RoutingType {
@@ -13,6 +14,7 @@ enum class RoutingType(val fileName: String) {
1 -> BLACK 1 -> BLACK
2 -> GLOBAL 2 -> GLOBAL
3 -> WHITE_IRAN 3 -> WHITE_IRAN
4 -> WHITE_RUSSIA
else -> WHITE else -> WHITE
} }
} }
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto package com.v2ray.ang.enums
/** /**
* VPN interface address configuration enum class * VPN interface address configuration enum class
@@ -11,6 +11,7 @@ import com.v2ray.ang.AngApplication
import es.dmoral.toasty.Toasty import es.dmoral.toasty.Toasty
import java.io.Serializable import java.io.Serializable
import java.net.URI import java.net.URI
import java.util.Locale
val Context.v2RayApplication: AngApplication? val Context.v2RayApplication: AngApplication?
get() = applicationContext as? AngApplication get() = applicationContext as? AngApplication
@@ -92,7 +93,7 @@ fun Long.toTrafficString(): String {
size /= DIVISOR size /= DIVISOR
unitIndex++ unitIndex++
} }
return String.format("%.1f %s", size, units[unitIndex]) return String.format(Locale.getDefault(), "%.1f %s", size, units[unitIndex])
} }
val URI.idnHost: String val URI.idnHost: String
@@ -105,6 +106,13 @@ val URI.idnHost: String
*/ */
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "") fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
/**
* Returns null if the string is null or blank, otherwise returns the string itself.
*
* @return The string or null.
*/
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
/** /**
* Converts the string to a Long value, or returns 0 if the conversion fails. * Converts the string to a Long value, or returns 0 if the conversion fails.
* *
@@ -168,7 +176,7 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
* *
* @return True if the CharSequence is not null and not empty, false otherwise. * @return True if the CharSequence is not null and not empty, false otherwise.
*/ */
fun CharSequence?.isNotNullEmpty(): Boolean = this != null && this.isNotEmpty() fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
fun String.concatUrl(vararg paths: String): String { fun String.concatUrl(vararg paths: String): String {
val builder = StringBuilder(this.trimEnd('/')) val builder = StringBuilder(this.trimEnd('/'))
@@ -181,4 +189,15 @@ fun String.concatUrl(vararg paths: String): String {
} }
return builder.toString() return builder.toString()
}
/**
* Helper function to match text either by Regex or literal string.
*/
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
if (keyword.isNullOrEmpty()) {
return true
}
return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase)
} }
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
object CustomFmt : FmtBase() { object CustomFmt : FmtBase() {
@@ -12,7 +12,7 @@ object CustomFmt : FmtBase() {
* @param str the JSON string to parse * @param str the JSON string to parse
* @return the parsed ProfileItem object, or null if parsing fails * @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 config = ProfileItem.create(EConfigType.CUSTOM)
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java) val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
@@ -1,9 +1,9 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -22,17 +22,17 @@ open class FmtBase {
val query = if (dicQuery != null) val query = if (dicQuery != null)
"?" + dicQuery.toList().joinToString( "?" + dicQuery.toList().joinToString(
separator = "&", separator = "&",
transform = { it.first + "=" + Utils.urlEncode(it.second) }) transform = { it.first + "=" + Utils.encodeURIComponent(it.second) })
else "" else ""
val url = String.format( val url = String.format(
"%s@%s:%s", "%s@%s:%s",
Utils.urlEncode(userInfo ?: ""), Utils.encodeURIComponent(userInfo ?: ""),
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())), Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
config.serverPort config.serverPort
) )
return "${url}${query}#${Utils.urlEncode(config.remarks)}" return "${url}${query}#${Utils.encodeURIComponent(config.remarks)}"
} }
/** /**
@@ -43,7 +43,7 @@ open class FmtBase {
*/ */
fun getQueryParam(uri: URI): Map<String, String> { fun getQueryParam(uri: URI): Map<String, String> {
return uri.rawQuery.split("&") return uri.rawQuery.split("&")
.associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } } .associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
} }
/** /**
@@ -67,6 +67,7 @@ open class FmtBase {
config.authority = queryParam["authority"] config.authority = queryParam["authority"]
config.xhttpMode = queryParam["mode"] config.xhttpMode = queryParam["mode"]
config.xhttpExtra = queryParam["extra"] config.xhttpExtra = queryParam["extra"]
config.finalMask = queryParam["fm"]
config.security = queryParam["security"] config.security = queryParam["security"]
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) { if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
@@ -83,6 +84,7 @@ open class FmtBase {
config.fingerPrint = queryParam["fp"] config.fingerPrint = queryParam["fp"]
config.alpn = queryParam["alpn"] config.alpn = queryParam["alpn"]
config.echConfigList = queryParam["ech"] config.echConfigList = queryParam["ech"]
config.pinnedCA256 = queryParam["pcs"]
config.publicKey = queryParam["pbk"] config.publicKey = queryParam["pbk"]
config.shortId = queryParam["sid"] config.shortId = queryParam["sid"]
config.spiderX = queryParam["spx"] config.spiderX = queryParam["spx"]
@@ -99,15 +101,17 @@ open class FmtBase {
fun getQueryDic(config: ProfileItem): HashMap<String, String> { fun getQueryDic(config: ProfileItem): HashMap<String, String> {
val dicQuery = HashMap<String, String>() val dicQuery = HashMap<String, String>()
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty() dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() } config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() } config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.echConfigList.let { if (it.isNotNullEmpty()) dicQuery["ech"] = it.orEmpty() } config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() } config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() } config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() } config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() } config.shortId?.nullIfBlank()?.let { dicQuery["sid"] = it }
config.mldsa65Verify.let { if (it.isNotNullEmpty()) dicQuery["pqv"] = it.orEmpty() } config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() } config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
// Add two keys for compatibility: "insecure" and "allowInsecure" // Add two keys for compatibility: "insecure" and "allowInsecure"
if (config.security == AppConfig.TLS) { if (config.security == AppConfig.TLS) {
val insecureFlag = if (config.insecure == true) "1" else "0" val insecureFlag = if (config.insecure == true) "1" else "0"
@@ -121,43 +125,45 @@ open class FmtBase {
when (networkType) { when (networkType) {
NetworkType.TCP -> { NetworkType.TCP -> {
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
} }
NetworkType.KCP -> { NetworkType.KCP -> {
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
config.seed.let { if (it.isNotNullEmpty()) dicQuery["seed"] = it.orEmpty() } config.seed?.nullIfBlank()?.let { dicQuery["seed"] = it }
} }
NetworkType.WS, NetworkType.HTTP_UPGRADE -> { NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
} }
NetworkType.XHTTP -> { NetworkType.XHTTP -> {
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() } config.xhttpMode?.nullIfBlank()?.let { dicQuery["mode"] = it }
config.xhttpExtra.let { if (it.isNotNullEmpty()) dicQuery["extra"] = it.orEmpty() } config.xhttpExtra?.nullIfBlank()?.let { dicQuery["extra"] = it }
} }
NetworkType.HTTP, NetworkType.H2 -> { NetworkType.HTTP, NetworkType.H2 -> {
dicQuery["type"] = "http" dicQuery["type"] = "http"
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() } config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() } config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
} }
// NetworkType.QUIC -> { // NetworkType.QUIC -> {
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty() // dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
// config.quicSecurity.let { if (it.isNotNullEmpty()) dicQuery["quicSecurity"] = it.orEmpty() } // config.quicSecurity?.nullIfBlank()?.let { dicQuery["quicSecurity"] = it }
// config.quicKey.let { if (it.isNotNullEmpty()) dicQuery["key"] = it.orEmpty() } // config.quicKey?.nullIfBlank()?.let { dicQuery["key"] = it }
// } // }
NetworkType.GRPC -> { NetworkType.GRPC -> {
config.mode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() } config.mode?.nullIfBlank()?.let { dicQuery["mode"] = it }
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() } config.authority?.nullIfBlank()?.let { dicQuery["authority"] = it }
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() } config.serviceName?.nullIfBlank()?.let { dicQuery["serviceName"] = it }
} }
else -> {}
} }
return dicQuery return dicQuery
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
@@ -1,13 +1,13 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.Hysteria2Bean
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -20,16 +20,17 @@ object Hysteria2Fmt : FmtBase() {
* @param str the Hysteria2 URI string to parse * @param str the Hysteria2 URI string to parse
* @return the parsed ProfileItem object, or null if parsing fails * @return the parsed ProfileItem object, or null if parsing fails
*/ */
fun parse(str: String): ProfileItem? { fun parse(str: String): ProfileItem {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false) var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.HYSTERIA2) val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
config.security = AppConfig.TLS config.security = AppConfig.TLS
config.network = NetworkType.HYSTERIA.type
if (!uri.rawQuery.isNullOrEmpty()) { if (!uri.rawQuery.isNullOrEmpty()) {
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
@@ -42,7 +43,7 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHopping.isNotNullEmpty()) { if (config.portHopping.isNotNullEmpty()) {
config.portHoppingInterval = queryParam["mportHopInt"] config.portHoppingInterval = queryParam["mportHopInt"]
} }
config.pinSHA256 = queryParam["pinSHA256"] config.pinnedCA256 = queryParam["pinSHA256"]
} }
@@ -59,8 +60,8 @@ object Hysteria2Fmt : FmtBase() {
val dicQuery = HashMap<String, String>() val dicQuery = HashMap<String, String>()
config.security.let { if (it != null) dicQuery["security"] = it } config.security.let { if (it != null) dicQuery["security"] = it }
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() } config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() } config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" } config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
if (config.obfsPassword.isNotNullEmpty()) { if (config.obfsPassword.isNotNullEmpty()) {
@@ -71,73 +72,45 @@ object Hysteria2Fmt : FmtBase() {
dicQuery["mport"] = config.portHopping.orEmpty() dicQuery["mport"] = config.portHopping.orEmpty()
} }
if (config.portHoppingInterval.isNotNullEmpty()) { 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.pinSHA256.isNotNullEmpty()) { if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty() dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
} }
return toUri(config, config.password, dicQuery) return toUri(config, config.password, dicQuery)
} }
/**
* Converts a ProfileItem object to a Hysteria2Bean object.
*
* @param config the ProfileItem object to convert
* @param socksPort the port number for the socks5 proxy
* @return the converted Hysteria2Bean object, or null if conversion fails
*/
fun toNativeConfig(config: ProfileItem, socksPort: Int): Hysteria2Bean? {
val obfs = if (config.obfsPassword.isNullOrEmpty()) null else
Hysteria2Bean.ObfsBean(
type = "salamander",
salamander = Hysteria2Bean.ObfsBean.SalamanderBean(
password = config.obfsPassword
)
)
val transport = if (config.portHopping.isNullOrEmpty()) null else
Hysteria2Bean.TransportBean(
type = "udp",
udp = Hysteria2Bean.TransportBean.TransportUdpBean(
hopInterval = (config.portHoppingInterval?.takeIf { it.isNotEmpty() } ?: "30") + "s"
)
)
val bandwidth = if (config.bandwidthDown.isNullOrEmpty() || config.bandwidthUp.isNullOrEmpty()) null else
Hysteria2Bean.BandwidthBean(
down = config.bandwidthDown,
up = config.bandwidthUp,
)
val server =
if (config.portHopping.isNullOrEmpty())
config.getServerAddressAndPort()
else
Utils.getIpv6Address(config.server) + ":" + config.portHopping
val bean = Hysteria2Bean(
server = server,
auth = config.password,
obfs = obfs,
transport = transport,
bandwidth = bandwidth,
socks5 = Hysteria2Bean.Socks5Bean(
listen = "$LOOPBACK:${socksPort}",
),
http = Hysteria2Bean.Socks5Bean(
listen = "$LOOPBACK:${socksPort}",
),
tls = Hysteria2Bean.TlsBean(
sni = config.sni ?: config.server,
insecure = config.insecure,
pinSHA256 = if (config.pinSHA256.isNullOrEmpty()) null else config.pinSHA256
)
)
return bean
}
/** /**
* Converts a ProfileItem object to an OutboundBean object. * Converts a ProfileItem object to an OutboundBean object.
* *
@@ -145,7 +118,24 @@ object Hysteria2Fmt : FmtBase() {
* @return the converted OutboundBean object, or null if conversion fails * @return the converted OutboundBean object, or null if conversion fails
*/ */
fun toOutbound(profileItem: ProfileItem): OutboundBean? { fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) ?: return null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean return outboundBean
} }
} }
@@ -1,13 +1,13 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.net.URI import java.net.URI
@@ -36,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
if (uri.port <= 0) return null if (uri.port <= 0) return null
if (uri.userInfo.isNullOrEmpty()) return null if (uri.userInfo.isNullOrEmpty()) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -83,9 +83,9 @@ object ShadowsocksFmt : FmtBase() {
if (indexSplit > 0) { if (indexSplit > 0) {
try { try {
config.remarks = config.remarks =
Utils.urlDecode(result.substring(indexSplit + 1, result.length)) Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
} catch (e: Exception) { } 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) result = result.substring(0, indexSplit)
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
@@ -23,7 +23,7 @@ object SocksFmt : FmtBase() {
if (uri.idnHost.isEmpty()) return null if (uri.idnHost.isEmpty()) return null
if (uri.port <= 0) return null if (uri.port <= 0) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -1,10 +1,10 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
@@ -18,12 +18,12 @@ object TrojanFmt : FmtBase() {
* @param str the Trojan URI string to parse * @param str the Trojan URI string to parse
* @return the parsed ProfileItem object, or null if parsing fails * @return the parsed ProfileItem object, or null if parsing fails
*/ */
fun parse(str: String): ProfileItem? { fun parse(str: String): ProfileItem {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false) var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.TROJAN) val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -1,9 +1,9 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
@@ -26,7 +26,7 @@ object VlessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -1,18 +1,18 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.VmessQRCode import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.net.URI import java.net.URI
@@ -34,7 +34,7 @@ object VmessFmt : FmtBase() {
var result = str.replace(EConfigType.VMESS.protocolScheme, "") var result = str.replace(EConfigType.VMESS.protocolScheme, "")
result = Utils.decode(result) result = Utils.decode(result)
if (TextUtils.isEmpty(result)) { if (TextUtils.isEmpty(result)) {
Log.w(AppConfig.TAG, "Toast decoding failed") LogUtil.w(AppConfig.TAG, "Toast decoding failed")
return null return null
} }
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
@@ -44,7 +44,7 @@ object VmessFmt : FmtBase() {
|| TextUtils.isEmpty(vmessQRCode.id) || TextUtils.isEmpty(vmessQRCode.id)
|| TextUtils.isEmpty(vmessQRCode.net) || TextUtils.isEmpty(vmessQRCode.net)
) { ) {
Log.w(AppConfig.TAG, "Toast incorrect protocol") LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
return null return null
} }
@@ -132,8 +132,8 @@ object VmessFmt : FmtBase() {
else -> {} else -> {}
} }
config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() } config.host?.nullIfBlank()?.let { vmessQRCode.host = it }
config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() } config.path?.nullIfBlank()?.let { vmessQRCode.path = it }
vmessQRCode.tls = config.security.orEmpty() vmessQRCode.tls = config.security.orEmpty()
vmessQRCode.sni = config.sni.orEmpty() vmessQRCode.sni = config.sni.orEmpty()
@@ -163,7 +163,7 @@ object VmessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -2,10 +2,11 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.V2rayConfigManager import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -25,14 +26,14 @@ object WireguardFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.secretKey = uri.userInfo.orEmpty() config.secretKey = uri.userInfo.orEmpty()
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4 config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.publicKey = queryParam["publickey"].orEmpty() config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.takeIf { it.isNotEmpty() } config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU) config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.reserved = queryParam["reserved"] ?: "0,0,0" config.reserved = queryParam["reserved"] ?: "0,0,0"
@@ -45,7 +46,7 @@ object WireguardFmt : FmtBase() {
* @param str the Wireguard configuration file string to parse * @param str the Wireguard configuration file string to parse
* @return the parsed ProfileItem object, or null if parsing fails * @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 config = ProfileItem.create(EConfigType.WIREGUARD)
val interfaceParams: MutableMap<String, String> = mutableMapOf() val interfaceParams: MutableMap<String, String> = mutableMapOf()
@@ -84,7 +85,7 @@ object WireguardFmt : FmtBase() {
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4 config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU) config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.publicKey = peerParams["publickey"].orEmpty() config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.takeIf { it.isNotEmpty() } config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
val endpoint = peerParams["endpoint"].orEmpty() val endpoint = peerParams["endpoint"].orEmpty()
val endpointParts = endpoint.split(":", limit = 2) val endpointParts = endpoint.split(":", limit = 2)
if (endpointParts.size == 2) { if (endpointParts.size == 2) {
@@ -113,7 +114,7 @@ object WireguardFmt : FmtBase() {
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",") wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
wireguard.peers?.firstOrNull()?.let { peer -> wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty() peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.takeIf { it.isNotEmpty() } peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}" peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
} }
wireguard.mtu = profileItem.mtu wireguard.mtu = profileItem.mtu
@@ -3,13 +3,15 @@ package com.v2ray.ang.handler
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2 import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.fmt.Hysteria2Fmt import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt import com.v2ray.ang.fmt.ShadowsocksFmt
@@ -20,6 +22,7 @@ import com.v2ray.ang.fmt.VmessFmt
import com.v2ray.ang.fmt.WireguardFmt import com.v2ray.ang.fmt.WireguardFmt
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.net.URI import java.net.URI
@@ -44,7 +47,7 @@ object AngConfigManager {
Utils.setClipboard(context, conf) Utils.setClipboard(context, conf)
} catch (e: Exception) { } 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 -1
} }
return 0 return 0
@@ -73,7 +76,7 @@ object AngConfigManager {
} }
return sb.lines().count() - 1 return sb.lines().count() - 1
} catch (e: Exception) { } 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 return -1
} }
} }
@@ -93,7 +96,7 @@ object AngConfigManager {
return QRCodeDecoder.createQRCode(conf) return QRCodeDecoder.createQRCode(conf)
} catch (e: Exception) { } 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 return null
} }
} }
@@ -110,19 +113,12 @@ object AngConfigManager {
if (guid == null) return -1 if (guid == null) return -1
val result = V2rayConfigManager.getV2rayConfig(context, guid) val result = V2rayConfigManager.getV2rayConfig(context, guid)
if (result.status) { if (result.status) {
val config = MmkvManager.decodeServerConfig(guid)
if (config?.configType == EConfigType.HYSTERIA2) {
val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort)
Utils.setClipboard(context, JsonUtil.toJsonPretty(hy2Config) + "\n" + result.content)
return 0
}
Utils.setClipboard(context, result.content) Utils.setClipboard(context, result.content)
} else { } else {
return -1 return -1
} }
} catch (e: Exception) { } 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 -1
} }
return 0 return 0
@@ -149,9 +145,10 @@ object AngConfigManager {
EConfigType.WIREGUARD -> WireguardFmt.toUri(config) EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config) EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
EConfigType.POLICYGROUP -> "" EConfigType.POLICYGROUP -> ""
else -> {}
} }
} catch (e: Exception) { } 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 "" return ""
} }
} }
@@ -170,7 +167,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append) count = parseBatchConfig(server, subid, append)
} }
if (count <= 0) { if (count <= 0) {
count = parseCustomConfigServer(server, subid) count = parseCustomConfigServer(server, subid, append)
} }
var countSub = parseBatchSubscription(server) var countSub = parseBatchSubscription(server)
@@ -206,7 +203,7 @@ object AngConfigManager {
} }
return count return count
} catch (e: Exception) { } 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 return 0
} }
@@ -224,49 +221,146 @@ object AngConfigManager {
if (servers == null) { if (servers == null) {
return 0 return 0
} }
val removedSelectedServer = // Find the currently selected server that matches the subscription ID
if (!TextUtils.isEmpty(subid) && !append) { val removedSelected = if (subid.isNotBlank() && !append) {
MmkvManager.decodeServerConfig( MmkvManager.getSelectServer()
MmkvManager.getSelectServer().orEmpty() .takeIf { it?.isNotBlank() == true }
)?.let { ?.let { MmkvManager.decodeServerConfig(it) }
if (it.subscriptionId == subid) { ?.takeIf { it.subscriptionId == subid }
return@let it } else {
} null
return@let null
}
} else {
null
}
if (!append) {
MmkvManager.removeServerViaSubid(subid)
} }
val subItem = MmkvManager.decodeSubscription(subid) val subItem = MmkvManager.decodeSubscription(subid)
var count = 0
// Parse all configs first (no I/O during parsing)
val configs = mutableListOf<ProfileItem>()
servers.lines() servers.lines()
.distinct() .distinct()
.reversed() .reversed()
.forEach { .forEach {
val resId = parseConfig(it, subid, subItem, removedSelectedServer) val config = parseConfig(it, subid, subItem)
if (resId == 0) { if (config != null) {
count++ configs.add(config)
} }
} }
return count
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
return configs.size
} catch (e: Exception) { } 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 return 0
} }
/**
* Batch save configurations to reduce serverList read/write operations.
* Reads serverList once, saves all configs, then writes serverList once.
*
* @param configs The list of ProfileItem to save.
* @param subid The subscription ID.
* @return Map of generated keys to their corresponding ProfileItem.
*/
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
val keyToProfile = mutableMapOf<String, ProfileItem>()
// Read serverList once
val serverList = MmkvManager.decodeServerList(subid)
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
configs.forEach { config ->
val key = Utils.getUuid()
// Save profile directly without updating serverList
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
if (!serverList.contains(key)) {
serverList.add(0, key)
if (needSetSelected) {
MmkvManager.setSelectServer(key)
needSetSelected = false
}
}
keyToProfile[key] = config
}
// Write serverList once
MmkvManager.encodeServerList(serverList, subid)
return keyToProfile
}
/**
* Finds a matched profile key from the given key-profile map using multi-level matching.
* Matching priority (from highest to lowest):
* 1. Exact match: server + port + password
* 2. Match by remarks (exact match)
* 3. Match by server + port
* 4. Match by server only
*
* @param keyToProfile Map of server keys to their ProfileItem
* @param target Target profile to match
* @return Matched key or null
*/
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty() || target == null) return null
// Level 1: Match by remarks
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks)
}?.key?.let { return it }
}
// Level 2: Exact match (server + port + password)
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
// Level 3: Match by server + port
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort)
}?.key?.let { return it }
// Level 4: Match by server only
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server)
}?.key?.let { return it }
return null
}
/**
* Case-insensitive trimmed string comparison.
*
* @param left First string
* @param right Second string
* @return True if both are non-empty and equal (case-insensitive, trimmed)
*/
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
}
/** /**
* Parses a custom configuration server. * Parses a custom configuration server.
* *
* @param server The server string. * @param server The server string.
* @param subid The subscription ID. * @param subid The subscription ID.
* @param append Whether to append the configurations.
* @return The number of configurations parsed. * @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) { if (server == null) {
return 0 return 0
} }
@@ -276,13 +370,17 @@ object AngConfigManager {
) { ) {
try { try {
val serverList: Array<Any> = val serverList: Array<Any> =
JsonUtil.fromJson(server, Array<Any>::class.java)?: arrayOf() JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) { if (serverList.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0 var count = 0
for (srv in serverList.reversed()) { for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "") MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1 count += 1
@@ -290,28 +388,36 @@ object AngConfigManager {
return count return count
} }
} catch (e: Exception) { } 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 { try {
// For compatibility // For compatibility
val config = CustomFmt.parse(server) ?: return 0 val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server) MmkvManager.encodeServerRaw(key, server)
return 1 return 1
} catch (e: Exception) { } 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 return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) { } else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try { try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol 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) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server) MmkvManager.encodeServerRaw(key, server)
return 1 return 1
} catch (e: Exception) { } 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 return 0
} else { } else {
@@ -321,22 +427,21 @@ object AngConfigManager {
/** /**
* Parses the configuration from a QR code or string. * Parses the configuration from a QR code or string.
* Only parses and returns ProfileItem, does not save.
* *
* @param str The configuration string. * @param str The configuration string.
* @param subid The subscription ID. * @param subid The subscription ID.
* @param subItem The subscription item. * @param subItem The subscription item.
* @param removedSelectedServer The removed selected server. * @return The parsed ProfileItem or null if parsing fails or filtered out.
* @return The result code.
*/ */
private fun parseConfig( private fun parseConfig(
str: String?, str: String?,
subid: String, subid: String,
subItem: SubscriptionItem?, subItem: SubscriptionItem?
removedSelectedServer: ProfileItem? ): ProfileItem? {
): Int {
try { try {
if (str == null || TextUtils.isEmpty(str)) { if (str == null || TextUtils.isEmpty(str)) {
return R.string.toast_none_data return null
} }
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) { val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
@@ -358,104 +463,111 @@ object AngConfigManager {
} }
if (config == null) { if (config == null) {
return R.string.toast_incorrect_protocol return null
} }
//filter
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) { // Apply filter
val matched = Regex(pattern = subItem.filter ?: "") if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
val matched = Regex(pattern = subItem?.filter.orEmpty())
.containsMatchIn(input = config.remarks) .containsMatchIn(input = config.remarks)
if (!matched) return -1 if (!matched) return null
} }
config.subscriptionId = subid config.subscriptionId = subid
val guid = MmkvManager.encodeServerConfig("", config) config.description = generateDescription(config)
if (removedSelectedServer != null &&
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort return config
) {
MmkvManager.setSelectServer(guid)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse config", e) LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
return -1 return null
} }
return 0
} }
/** /**
* Updates the configuration via all subscriptions. * Updates the configuration via all subscriptions.
* *
* @return The number of configurations updated. * @return Detailed result of the subscription update operation.
*/ */
fun updateConfigViaSubAll(): Int { fun updateConfigViaSubAll(): SubscriptionUpdateResult {
var count = 0 return try {
try { val subscriptions = MmkvManager.decodeSubscriptions()
MmkvManager.decodeSubscriptions().forEach { subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
count += updateConfigViaSub(it) acc + updateConfigViaSub(subscription)
} }
} catch (e: Exception) { } 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)
return 0 SubscriptionUpdateResult()
} }
return count
} }
/** /**
* Updates the configuration via a subscription. * Updates the configuration via a subscription.
* *
* @param it The subscription item. * @param it The subscription item.
* @return The number of configurations updated. * @return Subscription update result.
*/ */
fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int { fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
try { try {
if (TextUtils.isEmpty(it.first) // Check if disabled
|| TextUtils.isEmpty(it.second.remarks) if (!it.subscription.enabled) {
|| TextUtils.isEmpty(it.second.url) return SubscriptionUpdateResult(skipCount = 1)
}
// Validate subscription info
if (TextUtils.isEmpty(it.guid)
|| TextUtils.isEmpty(it.subscription.remarks)
|| TextUtils.isEmpty(it.subscription.url)
) { ) {
return 0 return SubscriptionUpdateResult(skipCount = 1)
} }
if (!it.second.enabled) {
return 0 val url = HttpUtil.toIdnUrl(it.subscription.url)
}
val url = HttpUtil.toIdnUrl(it.second.url)
if (!Utils.isValidUrl(url)) { if (!Utils.isValidUrl(url)) {
return 0 return SubscriptionUpdateResult(failureCount = 1)
} }
if (!it.second.allowInsecureUrl) { if (!it.subscription.allowInsecureUrl) {
if (!Utils.isValidSubUrl(url)) { if (!Utils.isValidSubUrl(url)) {
return 0 return SubscriptionUpdateResult(failureCount = 1)
} }
} }
Log.i(AppConfig.TAG, url) LogUtil.i(AppConfig.TAG, url)
val userAgent = it.second.userAgent val userAgent = it.subscription.userAgent
var configText = try { var configText = try {
val httpPort = SettingsManager.getHttpPort() val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort) HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
} catch (e: Exception) { } 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()) { if (configText.isEmpty()) {
configText = try { configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent) HttpUtil.getUrlContentWithUserAgent(url, userAgent)
} catch (e: Exception) { } 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)
"" ""
} }
} }
if (configText.isEmpty()) { if (configText.isEmpty()) {
return 0 return SubscriptionUpdateResult(failureCount = 1)
} }
val count = parseConfigViaSub(configText, it.first, false)
val count = parseConfigViaSub(configText, it.guid, false)
if (count > 0) { if (count > 0) {
it.second.lastUpdated = System.currentTimeMillis() it.subscription.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(it.first, it.second) MmkvManager.encodeSubscription(it.guid, it.subscription)
Log.i(AppConfig.TAG, "Subscription updated: ${it.second.remarks}, $count configs") LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
return SubscriptionUpdateResult(
configCount = count,
successCount = 1
)
} else {
// Got response but no valid configs parsed
return SubscriptionUpdateResult(failureCount = 1)
} }
return count
} catch (e: Exception) { } 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 0 return SubscriptionUpdateResult(failureCount = 1)
} }
} }
@@ -473,7 +585,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append) count = parseBatchConfig(server, subid, append)
} }
if (count <= 0) { if (count <= 0) {
count = parseCustomConfigServer(server, subid) count = parseCustomConfigServer(server, subid, append)
} }
return count return count
} }
@@ -487,7 +599,7 @@ object AngConfigManager {
private fun importUrlAsSubscription(url: String): Int { private fun importUrlAsSubscription(url: String): Int {
val subscriptions = MmkvManager.decodeSubscriptions() val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.forEach { subscriptions.forEach {
if (it.second.url == url) { if (it.subscription.url == url) {
return 0 return 0
} }
} }
@@ -498,4 +610,25 @@ object AngConfigManager {
MmkvManager.encodeSubscription("", subItem) MmkvManager.encodeSubscription("", subItem)
return 1 return 1
} }
/** Generates a description for the profile.
*
* @param profile The profile item.
* @return The generated description.
*/
fun generateDescription(profile: ProfileItem): String {
// Hide xxx:xxx:***/xxx.xxx.xxx.***
val server = profile.server
val port = profile.serverPort
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
val addrPart = server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
} ?: ""
return "$addrPart : ${port ?: ""}"
}
} }
@@ -1,12 +1,15 @@
package com.v2ray.ang.handler package com.v2ray.ang.handler
import com.tencent.mmkv.MMKV 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_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET 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.AssetUrlItem
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServerAffiliationInfo import com.v2ray.ang.dto.ServerAffiliationInfo
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.WebDavConfig import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -16,7 +19,6 @@ object MmkvManager {
//region private //region private
//private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
private const val ID_MAIN = "MAIN" private const val ID_MAIN = "MAIN"
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG" private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
private const val ID_SERVER_RAW = "SERVER_RAW" private const val ID_SERVER_RAW = "SERVER_RAW"
@@ -26,10 +28,10 @@ object MmkvManager {
private const val ID_SETTING = "SETTING" private const val ID_SETTING = "SETTING"
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER" private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS" private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
private const val KEY_SUB_IDS = "SUB_IDS" private const val KEY_SUB_IDS = "SUB_IDS"
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG" private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) } private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) } private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) } private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
@@ -42,6 +44,17 @@ object MmkvManager {
//region Server //region Server
/**
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
* This method is for migration purposes only.
*
* @return The JSON string of legacy server list, or null if not exists.
*/
fun readLegacyServerList(): String? {
return mainStorage.decodeString(KEY_ANG_CONFIGS)
}
/** /**
* Gets the selected server GUID. * Gets the selected server GUID.
* *
@@ -61,21 +74,31 @@ object MmkvManager {
} }
/** /**
* Encodes the server list. * Encodes the server list for a given subscription.
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
* *
* @param serverList The list of server GUIDs. * @param serverList The list of server GUIDs.
* @param subscriptionId The subscription ID.
*/ */
fun encodeServerList(serverList: MutableList<String>) { fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList)) val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
mainStorage.encode(key, JsonUtil.toJson(serverList))
} }
/** /**
* Decodes the server list. * Decodes the server list for a given subscription.
* If subscriptionId is empty, returns ungrouped servers.
* Otherwise, returns servers from the specified subscription's serverList.
* *
* @param subscriptionId The subscription ID.
* @return The list of server GUIDs. * @return The list of server GUIDs.
*/ */
fun decodeServerList(): MutableList<String> { fun decodeServerList(subscriptionId: String): MutableList<String> {
val json = mainStorage.decodeString(KEY_ANG_CONFIGS) val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
val json = mainStorage.decodeString(key)
return if (json.isNullOrBlank()) { return if (json.isNullOrBlank()) {
mutableListOf() mutableListOf()
} else { } else {
@@ -83,6 +106,30 @@ object MmkvManager {
} }
} }
/**
* Decodes all server list (merged from all subscriptions including default subscription).
* Use this when you need the complete server list.
*
* @return The list of all server GUIDs.
*/
fun decodeAllServerList(): MutableList<String> {
val allServers = mutableListOf<String>()
val subsList = decodeSubsList()
// If DEFAULT_SUBSCRIPTION_ID is not in the subscriptions list, add its servers
if (!subsList.contains(DEFAULT_SUBSCRIPTION_ID)) {
allServers.addAll(decodeServerList(DEFAULT_SUBSCRIPTION_ID))
}
// Add servers from all subscriptions
subsList.forEach { guid ->
allServers.addAll(decodeServerList(guid))
}
return allServers
}
/** /**
* Decodes the server configuration. * Decodes the server configuration.
* *
@@ -100,16 +147,6 @@ object MmkvManager {
return JsonUtil.fromJson(json, ProfileItem::class.java) return JsonUtil.fromJson(json, ProfileItem::class.java)
} }
// fun decodeProfileConfig(guid: String): ProfileLiteItem? {
// if (guid.isBlank()) {
// return null
// }
// val json = profileStorage.decodeString(guid)
// if (json.isNullOrBlank()) {
// return null
// }
// return JsonUtil.fromJson(json, ProfileLiteItem::class.java)
// }
/** /**
* Encodes the server configuration. * Encodes the server configuration.
@@ -121,25 +158,32 @@ object MmkvManager {
fun encodeServerConfig(guid: String, config: ProfileItem): String { fun encodeServerConfig(guid: String, config: ProfileItem): String {
val key = guid.ifBlank { Utils.getUuid() } val key = guid.ifBlank { Utils.getUuid() }
profileFullStorage.encode(key, JsonUtil.toJson(config)) profileFullStorage.encode(key, JsonUtil.toJson(config))
val serverList = decodeServerList()
// Use default subscription for servers without subscription
val subId = getSubscriptionId(config.subscriptionId)
val serverList = decodeServerList(subId)
if (!serverList.contains(key)) { if (!serverList.contains(key)) {
serverList.add(0, key) serverList.add(0, key)
encodeServerList(serverList) encodeServerList(serverList, subId)
if (getSelectServer().isNullOrBlank()) { if (getSelectServer().isNullOrBlank()) {
mainStorage.encode(KEY_SELECTED_SERVER, key) mainStorage.encode(KEY_SELECTED_SERVER, key)
} }
} }
// val profile = ProfileLiteItem(
// configType = config.configType,
// subscriptionId = config.subscriptionId,
// remarks = config.remarks,
// server = config.getProxyOutbound()?.getServerAddress(),
// serverPort = config.getProxyOutbound()?.getServerPort(),
// )
// profileStorage.encode(key, JsonUtil.toJson(profile))
return key return key
} }
/**
* Encodes the server configuration directly without updating serverList.
*
* @param key The server GUID.
* @param configJson The server configuration JSON string.
*/
fun encodeProfileDirect(key: String, configJson: String) {
profileFullStorage.encode(key, configJson)
}
/** /**
* Removes the server configuration. * Removes the server configuration.
* *
@@ -149,33 +193,44 @@ object MmkvManager {
if (guid.isBlank()) { if (guid.isBlank()) {
return return
} }
// Get config to determine which subscription to update
val config = decodeServerConfig(guid)
val subId = getSubscriptionId(config?.subscriptionId)
// Remove from appropriate server list
val serverList = decodeServerList(subId)
serverList.remove(guid)
encodeServerList(serverList, subId)
// Clean up storage
if (getSelectServer() == guid) { if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER) mainStorage.remove(KEY_SELECTED_SERVER)
} }
val serverList = decodeServerList()
serverList.remove(guid)
encodeServerList(serverList)
profileFullStorage.remove(guid) profileFullStorage.remove(guid)
//profileStorage.remove(guid)
serverAffStorage.remove(guid) serverAffStorage.remove(guid)
} }
/** /**
* Removes the server configurations via subscription ID. * Removes the server configurations via subscription ID.
* *
* @param subid The subscription ID. * @param subscriptionId The subscription ID.
*/ */
fun removeServerViaSubid(subid: String) { fun removeServerViaSubid(subscriptionId: String?) {
if (subid.isBlank()) { val subId = getSubscriptionId(subscriptionId)
return val serverList = decodeServerList(subId)
}
profileFullStorage.allKeys()?.forEach { key -> // Remove all servers in the list
decodeServerConfig(key)?.let { config -> serverList.forEach { guid ->
if (config.subscriptionId == subid) { if (getSelectServer() == guid) {
removeServer(key) mainStorage.remove(KEY_SELECTED_SERVER)
}
} }
profileFullStorage.remove(guid)
serverAffStorage.remove(guid)
} }
serverList.clear()
encodeServerList(serverList, subId)
} }
/** /**
@@ -233,7 +288,6 @@ object MmkvManager {
val count = profileFullStorage.allKeys()?.count() ?: 0 val count = profileFullStorage.allKeys()?.count() ?: 0
mainStorage.clearAll() mainStorage.clearAll()
profileFullStorage.clearAll() profileFullStorage.clearAll()
//profileStorage.clearAll()
serverAffStorage.clearAll() serverAffStorage.clearAll()
return count return count
} }
@@ -290,6 +344,10 @@ object MmkvManager {
//region Subscriptions //region Subscriptions
private fun getSubscriptionId(subscriptionId: String?): String {
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
}
/** /**
* Initializes the subscription list. * Initializes the subscription list.
*/ */
@@ -309,15 +367,15 @@ object MmkvManager {
* *
* @return The list of subscriptions. * @return The list of subscriptions.
*/ */
fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> { fun decodeSubscriptions(): List<SubscriptionCache> {
initSubsList() initSubsList()
val subscriptions = mutableListOf<Pair<String, SubscriptionItem>>() val subscriptions = mutableListOf<SubscriptionCache>()
decodeSubsList().forEach { key -> decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key) val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) { if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem() val item = JsonUtil.fromJson(json, SubscriptionItem::class.java) ?: SubscriptionItem()
subscriptions.add(Pair(key, item)) subscriptions.add(SubscriptionCache(key, item))
} }
} }
return subscriptions return subscriptions
@@ -384,7 +442,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) { return if (json.isNullOrBlank()) {
mutableListOf() mutableListOf()
} else { } else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList()?: mutableListOf() JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
} }
} }
@@ -397,16 +455,16 @@ object MmkvManager {
* *
* @return The list of asset URLs. * @return The list of asset URLs.
*/ */
fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> { fun decodeAssetUrls(): List<AssetUrlCache> {
val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>() val assetUrlItems = mutableListOf<AssetUrlCache>()
assetStorage.allKeys()?.forEach { key -> assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key) val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) { if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem() val item = JsonUtil.fromJson(json, AssetUrlItem::class.java) ?: AssetUrlItem()
assetUrlItems.add(Pair(key, item)) assetUrlItems.add(AssetUrlCache(key, item))
} }
} }
return assetUrlItems.sortedBy { (_, value) -> value.addedTime } return assetUrlItems.sortedBy { it.assetUrl.addedTime }
} }
/** /**
@@ -452,7 +510,7 @@ object MmkvManager {
fun decodeRoutingRulesets(): MutableList<RulesetItem>? { fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET) val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
if (ruleset.isNullOrEmpty()) return null if (ruleset.isNullOrEmpty()) return null
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList()?: mutableListOf() return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
} }
/** /**
@@ -16,6 +16,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toSpeedString import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.ui.MainActivity import com.v2ray.ang.ui.MainActivity
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -30,6 +31,7 @@ object NotificationManager {
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1 private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2 private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
private const val NOTIFICATION_ICON_THRESHOLD = 3000 private const val NOTIFICATION_ICON_THRESHOLD = 3000
private const val QUERY_INTERVAL_MS = 3000L
private var lastQueryTime = 0L private var lastQueryTime = 0L
private var mBuilder: NotificationCompat.Builder? = null private var mBuilder: NotificationCompat.Builder? = null
@@ -44,7 +46,6 @@ object NotificationManager {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
lastQueryTime = System.currentTimeMillis()
var lastZeroSpeed = false var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags() val outboundTags = currentConfig?.getAllOutboundTags()
outboundTags?.remove(AppConfig.TAG_DIRECT) outboundTags?.remove(AppConfig.TAG_DIRECT)
@@ -52,7 +53,17 @@ object NotificationManager {
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch { speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) { while (isActive) {
val queryTime = System.currentTimeMillis() val queryTime = System.currentTimeMillis()
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0 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
delay(QUERY_INTERVAL_MS)
continue
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyTotal = 0L var proxyTotal = 0L
val text = StringBuilder() val text = StringBuilder()
outboundTags?.forEach { outboundTags?.forEach {
@@ -78,7 +89,7 @@ object NotificationManager {
} }
lastZeroSpeed = zeroSpeed lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime lastQueryTime = queryTime
delay(3000) delay(QUERY_INTERVAL_MS)
} }
} }
} }
@@ -89,6 +100,10 @@ object NotificationManager {
*/ */
fun showNotification(currentConfig: ProfileItem?) { fun showNotification(currentConfig: ProfileItem?) {
val service = getService() ?: return val service = getService() ?: return
// Reset last query time to avoid querying stats too soon after showing the notification
lastQueryTime = System.currentTimeMillis()
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
val startMainIntent = Intent(service, MainActivity::class.java) val startMainIntent = Intent(service, MainActivity::class.java)
@@ -1,141 +0,0 @@
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.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.service.ProcessService
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import java.io.File
object PluginServiceManager {
private const val HYSTERIA2 = "libhysteria2.so"
private val procService: ProcessService by lazy {
ProcessService()
}
/**
* Run the plugin based on the provided configuration.
*
* @param context The context to use.
* @param config The profile configuration.
* @param socksPort The port information.
*/
fun runPlugin(context: Context, config: ProfileItem?, socksPort: Int?) {
Log.i(AppConfig.TAG, "Starting plugin execution")
if (config == null) {
Log.w(AppConfig.TAG, "Cannot run plugin: config is null")
return
}
try {
if (config.configType == EConfigType.HYSTERIA2) {
if (socksPort == null) {
Log.w(AppConfig.TAG, "Cannot run plugin: socksPort is null")
return
}
Log.i(AppConfig.TAG, "Running Hysteria2 plugin")
val configFile = genConfigHy2(context, config, socksPort) ?: return
val cmd = genCmdHy2(context, configFile)
procService.runProcess(context, cmd)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error running plugin", e)
}
}
/**
* Stop the running plugin.
*/
fun stopPlugin() {
stopHy2()
}
/**
* Perform a real ping using Hysteria2.
*
* @param context The context to use.
* @param config The profile configuration.
* @return The ping delay in milliseconds, or -1 if it fails.
*/
fun realPingHy2(context: Context, config: ProfileItem?): Long {
Log.i(AppConfig.TAG, "realPingHy2")
val retFailure = -1L
if (config?.configType?.equals(EConfigType.HYSTERIA2) == true) {
val socksPort = Utils.findFreePort(listOf(0))
val configFile = genConfigHy2(context, config, socksPort) ?: return retFailure
val cmd = genCmdHy2(context, configFile)
val proc = ProcessService()
proc.runProcess(context, cmd)
Thread.sleep(1000L)
val delay = SpeedtestManager.testConnection(context, socksPort)
proc.stopProcess()
return delay.first
}
return retFailure
}
/**
* Generate the configuration file for Hysteria2.
*
* @param context The context to use.
* @param config The profile configuration.
* @param socksPort The port information.
* @return The generated configuration file.
*/
private fun genConfigHy2(context: Context, config: ProfileItem, socksPort: Int): File? {
Log.i(AppConfig.TAG, "runPlugin $HYSTERIA2")
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort) ?: return null
val configFile = File(context.noBackupFilesDir, "hy2_${SystemClock.elapsedRealtime()}.json")
Log.i(AppConfig.TAG, "runPlugin ${configFile.absolutePath}")
configFile.parentFile?.mkdirs()
configFile.writeText(JsonUtil.toJson(hy2Config))
Log.i(AppConfig.TAG, JsonUtil.toJson(hy2Config))
return configFile
}
/**
* Generate the command to run Hysteria2.
*
* @param context The context to use.
* @param configFile The configuration file.
* @return The command to run Hysteria2.
*/
private fun genCmdHy2(context: Context, configFile: File): MutableList<String> {
return mutableListOf(
File(context.applicationInfo.nativeLibraryDir, HYSTERIA2).absolutePath,
"--disable-update-check",
"--config",
configFile.absolutePath,
"--log-level",
"warn",
"client"
)
}
/**
* Stop the Hysteria2 process.
*/
private fun stopHy2() {
try {
Log.i(AppConfig.TAG, "$HYSTERIA2 destroy")
procService.stopProcess()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop Hysteria2 process", e)
}
}
}
@@ -2,37 +2,57 @@ package com.v2ray.ang.handler
import android.content.Context import android.content.Context
import android.content.res.AssetManager import android.content.res.AssetManager
import android.os.Build
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.Language
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RoutingType
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.VpnInterfaceAddressConfig import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerList import com.v2ray.ang.handler.MmkvManager.decodeSubsList
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.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.Collections import java.util.Collections
import java.util.Locale import java.util.Locale
import kotlin.random.Random
object SettingsManager { object SettingsManager {
@Volatile
private var runtimeSocksPort: Int? = null
fun initApp(context: Context) {
ensureDefaultSettings()
//ensureDefaultSubscription()
initRoutingRulesets(context)
migrateServerListToSubscriptions()
migrateHysteria2PinSHA256()
}
/** /**
* Initialize routing rulesets. * Initialize routing rulesets.
* @param context The application context. * @param context The application context.
*/ */
fun initRoutingRulesets(context: Context) { private fun initRoutingRulesets(context: Context) {
val exist = MmkvManager.decodeRoutingRulesets() val exist = MmkvManager.decodeRoutingRulesets()
if (exist.isNullOrEmpty()) { if (exist.isNullOrEmpty()) {
val rulesetList = getPresetRoutingRulesets(context) val rulesetList = getPresetRoutingRulesets(context)
@@ -85,7 +105,7 @@ object SettingsManager {
resetRoutingRulesetsCommon(rulesetList) resetRoutingRulesetsCommon(rulesetList)
return true return true
} catch (e: Exception) { } 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 return false
} }
} }
@@ -204,7 +224,7 @@ object SettingsManager {
* @param toPosition The position to swap to. * @param toPosition The position to swap to.
*/ */
fun swapSubscriptions(fromPosition: Int, toPosition: Int) { fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
val subsList = MmkvManager.decodeSubsList() val subsList = decodeSubsList()
if (subsList.isEmpty()) return if (subsList.isEmpty()) return
Collections.swap(subsList, fromPosition, toPosition) Collections.swap(subsList, fromPosition, toPosition)
@@ -220,14 +240,37 @@ object SettingsManager {
if (remarks.isNullOrEmpty()) { if (remarks.isNullOrEmpty()) {
return null return null
} }
val serverList = decodeServerList() val serverList = decodeAllServerList()
for (guid in serverList) { return serverList
val profile = decodeServerConfig(guid) .mapNotNull { guid -> decodeServerConfig(guid) }
if (profile != null && profile.remarks == remarks) { .firstOrNull { it.remarks == remarks }
return profile }
}
/**
* 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) {
// LogUtil.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
// return
// }
// Remove the subscription
removeSubscription(subid)
// After removal, check if there are any subscriptions left. If not, create a default subscription.
val subsList2 = decodeSubsList()
if (subsList2.isNotEmpty()) {
return
} }
return null
val defaultSub = SubscriptionItem(
remarks = "Default",
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
} }
/** /**
@@ -235,7 +278,30 @@ object SettingsManager {
* @return The SOCKS port. * @return The SOCKS port.
*/ */
fun getSocksPort(): Int { 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() }
} }
/** /**
@@ -246,6 +312,18 @@ object SettingsManager {
return getSocksPort() + if (Utils.isXray()) 0 else 1 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 if (Utils.isXray()) {
Random.nextInt(10000, 65536)
} else {
Random.nextInt(10000, 65535)
}
}
/** /**
* Initialize assets. * Initialize assets.
* @param context The application context. * @param context The application context.
@@ -255,7 +333,7 @@ object SettingsManager {
val extFolder = Utils.userAssetPath(context) val extFolder = Utils.userAssetPath(context)
try { try {
val geo = arrayOf("geosite.dat", "geoip.dat") val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
assets.list("") assets.list("")
?.filter { geo.contains(it) } ?.filter { geo.contains(it) }
?.filter { !File(extFolder, it).exists() } ?.filter { !File(extFolder, it).exists() }
@@ -266,10 +344,10 @@ object SettingsManager {
input.copyTo(output) 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) { } catch (e: Exception) {
Log.e(ANG_PACKAGE, "asset copy failed", e) LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
} }
} }
@@ -386,12 +464,43 @@ object SettingsManager {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true) return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
} }
/**
* Check if VPN mode is enabled.
* @return True if VPN mode is enabled, false otherwise.
*/
fun isVpnMode(): Boolean {
val mode = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE)
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. * Ensure default settings are present in MMKV.
*/ */
fun ensureDefaultSettings() { private fun ensureDefaultSettings() {
// Write defaults in the exact order requested by the user // 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_DNS, AppConfig.DNS_VPN)
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString()) ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL) ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
@@ -412,4 +521,100 @@ object SettingsManager {
MmkvManager.encodeSettings(key, default) MmkvManager.encodeSettings(key, default)
} }
} }
private fun migrateHysteria2PinSHA256() {
// Check if migration has already been done
val migrationKey = "hysteria2_pin_sha256_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
val serverList = decodeAllServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid) ?: continue
if (profile.configType != EConfigType.HYSTERIA2) {
continue
}
if (profile.pinSHA256.isNullOrEmpty() || !profile.pinnedCA256.isNullOrEmpty()) {
continue
}
profile.pinnedCA256 = profile.pinSHA256
profile.pinSHA256 = null
MmkvManager.encodeServerConfig(guid, profile)
}
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
* This method should be called once during app initialization after the storage structure change.
* Servers are grouped by their subscriptionId into respective subscription's serverList.
* Servers without subscription are moved to the default subscription.
* After migration, KEY_ANG_CONFIGS is removed.
*/
private fun migrateServerListToSubscriptions() {
// Check if migration has already been done
val migrationKey = "server_list_to_subscriptions_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
// Ensure default subscription exists before migration
ensureDefaultSubscription()
// Read existing server list from legacy KEY_ANG_CONFIGS
val oldJson = MmkvManager.readLegacyServerList()
if (oldJson.isNullOrBlank()) {
// No data to migrate, mark as done
MmkvManager.encodeSettings(migrationKey, true)
return
}
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true)
return
}
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
// Group servers by subscription (use default subscription for empty subscriptionId)
guids.forEach { guid ->
val config = decodeServerConfig(guid) ?: return@forEach
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
}
// Update each subscription's serverList (including default subscription)
subscriptionServerMap.forEach { (subId, serverGuids) ->
MmkvManager.encodeServerList(serverGuids, subId)
}
// Mark migration as complete
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Ensures the default subscription exists for ungrouped servers.
* This subscription is used internally to store servers without a subscription.
* Made public for migration in SettingsManager.
*/
private fun ensureDefaultSubscription() {
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
val defaultSub = SubscriptionItem(
remarks = "Default",
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
// Move top
val subsList = decodeSubsList()
if (subsList.count() > 1) {
swapSubscriptions(0, subsList.count() - 1)
}
}
}
} }
@@ -2,12 +2,12 @@ package com.v2ray.ang.handler
import android.content.Context import android.content.Context
import android.os.SystemClock import android.os.SystemClock
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.IPAPIInfo import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import java.io.IOException import java.io.IOException
@@ -62,11 +62,11 @@ object SpeedtestManager {
socket.close() socket.close()
return time return time
} catch (e: UnknownHostException) { } catch (e: UnknownHostException) {
Log.e(AppConfig.TAG, "Unknown host: $url", e) LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e") LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
} catch (e: Exception) { } 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 return -1
} }
@@ -108,10 +108,10 @@ object SpeedtestManager {
) )
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(AppConfig.TAG, "Connection test IOException", e) LogUtil.e(AppConfig.TAG, "Connection test IOException", e)
result = context.getString(R.string.connection_test_error, e.message) result = context.getString(R.string.connection_test_error, e.message)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Connection test Exception", e) LogUtil.e(AppConfig.TAG, "Connection test Exception", e)
result = context.getString(R.string.connection_test_error, e.message) result = context.getString(R.string.connection_test_error, e.message)
} finally { } finally {
conn.disconnect() conn.disconnect()
@@ -5,13 +5,13 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.util.LogUtil
object SubscriptionUpdater { object SubscriptionUpdater {
@@ -34,12 +34,12 @@ object SubscriptionUpdater {
*/ */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Log.i(AppConfig.TAG, "subscription automatic update starting") LogUtil.i(AppConfig.TAG, "subscription automatic update starting")
val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate } val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
for (sub in subs) { for (sub in subs) {
val subItem = sub.second val subItem = sub.subscription
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL) notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
@@ -52,8 +52,8 @@ object SubscriptionUpdater {
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
notificationManager.notify(3, notification.build()) notificationManager.notify(3, notification.build())
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}") LogUtil.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
AngConfigManager.updateConfigViaSub(Pair(sub.first, subItem)) AngConfigManager.updateConfigViaSub(sub)
notification.setContentText("Updating ${subItem.remarks}") notification.setContentText("Updating ${subItem.remarks}")
} }
notificationManager.cancel(3) notificationManager.cancel(3)
@@ -2,7 +2,6 @@ package com.v2ray.ang.handler
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.CheckUpdateResult import com.v2ray.ang.dto.CheckUpdateResult
@@ -10,6 +9,7 @@ import com.v2ray.ang.dto.GitHubRelease
import com.v2ray.ang.extension.concatUrl import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -42,7 +42,7 @@ object UpdateCheckerManager {
} }
val latestVersion = latestRelease.tagName.removePrefix("v") val latestVersion = latestRelease.tagName.removePrefix("v")
Log.i( LogUtil.i(
AppConfig.TAG, AppConfig.TAG,
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})" "Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
) )
@@ -69,27 +69,27 @@ object UpdateCheckerManager {
try { try {
val apkFile = File(context.cacheDir, "update.apk") val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}") LogUtil.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
FileOutputStream(apkFile).use { outputStream -> FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream -> connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
} }
} }
Log.i(AppConfig.TAG, "APK download completed") LogUtil.i(AppConfig.TAG, "APK download completed")
return@withContext apkFile return@withContext apkFile
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}") LogUtil.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
return@withContext null return@withContext null
} finally { } finally {
try { try {
connection.disconnect() connection.disconnect()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}") LogUtil.e(AppConfig.TAG, "Error closing connection: ${e.message}")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}") LogUtil.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
return@withContext null return@withContext null
} }
} }
@@ -1,8 +1,8 @@
package com.v2ray.ang.handler package com.v2ray.ang.handler
import android.content.Context import android.content.Context
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import go.Seq import go.Seq
import libv2ray.CoreCallbackHandler import libv2ray.CoreCallbackHandler
@@ -32,14 +32,14 @@ object V2RayNativeManager {
val assetPath = Utils.userAssetPath(context) val assetPath = Utils.userAssetPath(context)
val deviceId = Utils.getDeviceIdForXUDPBaseKey() val deviceId = Utils.getDeviceIdForXUDPBaseKey()
Libv2ray.initCoreEnv(assetPath, deviceId) 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) { } 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) initialized.set(false)
throw e throw e
} }
} else { } else {
Log.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping") LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
} }
} }
@@ -53,7 +53,7 @@ object V2RayNativeManager {
return try { return try {
Libv2ray.checkVersionX() Libv2ray.checkVersionX()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check V2Ray version", e) LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
"Unknown" "Unknown"
} }
} }
@@ -69,7 +69,7 @@ object V2RayNativeManager {
return try { return try {
Libv2ray.measureOutboundDelay(config, testUrl) Libv2ray.measureOutboundDelay(config, testUrl)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e) LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
-1L -1L
} }
} }
@@ -84,7 +84,7 @@ object V2RayNativeManager {
return try { return try {
Libv2ray.newCoreController(handler) Libv2ray.newCoreController(handler)
} catch (e: Exception) { } 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 throw e
} }
} }
@@ -5,17 +5,20 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.system.OsConstants
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.service.ServiceControl
import com.v2ray.ang.service.V2RayProxyOnlyService import com.v2ray.ang.service.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -23,18 +26,26 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libv2ray.CoreCallbackHandler import libv2ray.CoreCallbackHandler
import libv2ray.CoreController import libv2ray.CoreController
import libv2ray.ProcessFinder
import java.lang.ref.SoftReference import java.lang.ref.SoftReference
import java.net.InetSocketAddress
object V2RayServiceManager { object V2RayServiceManager {
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback()) private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler() private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null private var currentConfig: ProfileItem? = null
private var processFinder: XrayProcessFinder? = null
var serviceControl: SoftReference<ServiceControl>? = null var serviceControl: SoftReference<ServiceControl>? = null
set(value) { set(value) {
field = value field = value
V2RayNativeManager.initCoreEnv(value?.get()?.getService()) val service = value?.get()?.getService()
V2RayNativeManager.initCoreEnv(service)
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
processFinder = XrayProcessFinder(service)
coreController.registerProcessFinder(processFinder)
}
} }
/** /**
@@ -57,9 +68,12 @@ object V2RayServiceManager {
* @param guid The GUID of the server configuration to use (optional). * @param guid The GUID of the server configuration to use (optional).
*/ */
fun startVService(context: Context, guid: String? = null) { fun startVService(context: Context, guid: String? = null) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) { if (guid != null) {
MmkvManager.setSelectServer(guid) MmkvManager.setSelectServer(guid)
} }
startContextService(context) startContextService(context)
} }
@@ -91,15 +105,33 @@ object V2RayServiceManager {
*/ */
private fun startContextService(context: Context) { private fun startContextService(context: Context) {
if (coreController.isRunning) { if (coreController.isRunning) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return return
} }
val guid = MmkvManager.getSelectServer() ?: return
val config = MmkvManager.decodeServerConfig(guid) ?: return val guid = MmkvManager.getSelectServer()
if (guid == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return
}
if (config.configType != EConfigType.CUSTOM if (config.configType != EConfigType.CUSTOM
&& config.configType != EConfigType.POLICYGROUP && config.configType != EConfigType.POLICYGROUP
&& !Utils.isValidUrl(config.server) && !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty()) && !Utils.isPureIpAddress(config.server.orEmpty())
) return ) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
return
}
// refresh socks port when enabled dynamic socks port
SettingsManager.refreshRuntimeSocksPort()
// val result = V2rayConfigUtil.getV2rayConfig(context, guid) // val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) return // if (!result.status) return
@@ -108,12 +140,21 @@ object V2RayServiceManager {
} else { } else {
context.toast(R.string.toast_services_start) context.toast(R.string.toast_services_start)
} }
val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: AppConfig.VPN) == AppConfig.VPN) {
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, V2RayVpnService::class.java) Intent(context.applicationContext, V2RayVpnService::class.java)
} else { } else {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, V2RayProxyOnlyService::class.java) Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
} }
ContextCompat.startForegroundService(context, intent)
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
}
} }
/** /**
@@ -123,15 +164,35 @@ object V2RayServiceManager {
*/ */
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean { fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
if (coreController.isRunning) { if (coreController.isRunning) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false return false
} }
val service = getService() ?: return false val service = getService()
val guid = MmkvManager.getSelectServer() ?: return false if (service == null) {
val config = MmkvManager.decodeServerConfig(guid) ?: return false LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
if (!result.status)
return false return false
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return false
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return false
}
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
LogUtil.d(AppConfig.TAG, result.content)
if (!result.status) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
return false
}
try { try {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE) val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
@@ -140,7 +201,7 @@ object V2RayServiceManager {
mFilter.addAction(Intent.ACTION_USER_PRESENT) mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags()) ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e) LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
return false return false
} }
@@ -151,13 +212,15 @@ object V2RayServiceManager {
} }
try { try {
NotificationManager.showNotification(currentConfig)
coreController.startLoop(result.content, tunFd) coreController.startLoop(result.content, tunFd)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to start Core loop", e) LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
return false return false
} }
if (coreController.isRunning == false) { if (coreController.isRunning == false) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "") MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
NotificationManager.cancelNotification() NotificationManager.cancelNotification()
return false return false
@@ -165,12 +228,10 @@ object V2RayServiceManager {
try { try {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "") MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.showNotification(currentConfig)
NotificationManager.startSpeedNotification(currentConfig) NotificationManager.startSpeedNotification(currentConfig)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
PluginServiceManager.runPlugin(service, config, result.socksPort)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to startup service", e) LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
return false return false
} }
return true return true
@@ -189,7 +250,7 @@ object V2RayServiceManager {
try { try {
coreController.stopLoop() coreController.stopLoop()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e) LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
} }
} }
} }
@@ -200,9 +261,8 @@ object V2RayServiceManager {
try { try {
service.unregisterReceiver(mMsgReceive) service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e) LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
} }
PluginServiceManager.stopPlugin()
return true return true
} }
@@ -235,14 +295,14 @@ object V2RayServiceManager {
try { try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl()) time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e) LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message" errorStr = e.message?.substringAfter("\":") ?: "empty message"
} }
if (time == -1L) { if (time == -1L) {
try { try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true)) time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e) LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message" errorStr = e.message?.substringAfter("\":") ?: "empty message"
} }
} }
@@ -294,7 +354,7 @@ object V2RayServiceManager {
serviceControl.stopService() serviceControl.stopService()
0 0
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop service in callback", e) LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
-1 -1
} }
} }
@@ -310,6 +370,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 (e: Exception) {
-1L
}
}
}
/** /**
* Broadcast receiver for handling messages sent to the service. * Broadcast receiver for handling messages sent to the service.
* Handles registration, service control, and screen events. * Handles registration, service control, and screen events.
@@ -341,12 +438,12 @@ object V2RayServiceManager {
} }
AppConfig.MSG_STATE_STOP -> { AppConfig.MSG_STATE_STOP -> {
Log.i(AppConfig.TAG, "Stop Service") LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
serviceControl.stopService() serviceControl.stopService()
} }
AppConfig.MSG_STATE_RESTART -> { AppConfig.MSG_STATE_RESTART -> {
Log.i(AppConfig.TAG, "Restart Service") LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService() serviceControl.stopService()
Thread.sleep(500L) Thread.sleep(500L)
startVService(serviceControl.getService()) startVService(serviceControl.getService())
@@ -359,12 +456,12 @@ object V2RayServiceManager {
when (intent?.action) { when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> { Intent.ACTION_SCREEN_OFF -> {
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats") LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification(currentConfig) NotificationManager.stopSpeedNotification(currentConfig)
} }
Intent.ACTION_SCREEN_ON -> { Intent.ACTION_SCREEN_ON -> {
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats") LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification(currentConfig) NotificationManager.startSpeedNotification(currentConfig)
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,8 @@
package com.v2ray.ang.handler package com.v2ray.ang.handler
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.WebDavConfig import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Credentials import okhttp3.Credentials
@@ -35,18 +35,18 @@ object WebDavManager {
} }
/** /**
* Upload a local file to a remote relative path under the configured remoteBasePath. * Upload a local file to a remote file name under the configured remoteBasePath.
* The provided `remoteRelativePath` should be relative (e.g. "backup_ng.zip"). * The provided `remoteFileName` should be a file name (e.g. "backup_ng.zip").
* The method will attempt to create parent directories via MKCOL before PUT. * The method will attempt to create parent directories via MKCOL before PUT.
* *
* @param localFile File to upload. * @param localFile File to upload.
* @param remoteRelativePath Remote path relative to configured remoteBasePath. * @param remoteFileName Remote file name relative to configured remoteBasePath.
* @return true if upload succeeded (HTTP 2xx), false otherwise. * @return true if upload succeeded (HTTP 2xx), false otherwise.
*/ */
suspend fun uploadFile(localFile: File, remoteRelativePath: String): Boolean = withContext(Dispatchers.IO) { suspend fun uploadFile(localFile: File, remoteFileName: String): Boolean = withContext(Dispatchers.IO) {
val remote = buildRemoteUrl(remoteFileName)
try { try {
val cl = client ?: return@withContext false val cl = client ?: return@withContext false
val remote = buildRemoteUrl(remoteRelativePath)
// Ensure parent directories exist // Ensure parent directories exist
val dirPath = remote.substringBeforeLast('/') val dirPath = remote.substringBeforeLast('/')
@@ -67,14 +67,14 @@ object WebDavManager {
cl.newCall(req).execute().use { resp -> cl.newCall(req).execute().use { resp ->
val success = resp.isSuccessful val success = resp.isSuccessful
if (success) { if (success) {
Log.i(AppConfig.TAG, "WebDAV upload success: $remoteRelativePath") LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
} else { } else {
Log.e(AppConfig.TAG, "WebDAV upload failed: $remoteRelativePath (HTTP ${resp.code})") LogUtil.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
} }
return@withContext success return@withContext success
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload exception: $remoteRelativePath", e) LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
return@withContext false return@withContext false
} }
} }
@@ -82,18 +82,18 @@ object WebDavManager {
/** /**
* Download a remote file (relative to configured remoteBasePath) into a local file. * Download a remote file (relative to configured remoteBasePath) into a local file.
* *
* @param remoteRelativePath Remote path relative to configured remoteBasePath. * @param remoteFileName Remote file name relative to configured remoteBasePath.
* @param destFile Local destination file to write to. * @param destFile Local destination file to write to.
* @return true if download and write succeeded, false otherwise. * @return true if download and write succeeded, false otherwise.
*/ */
suspend fun downloadFile(remoteRelativePath: String, destFile: File): Boolean = withContext(Dispatchers.IO) { suspend fun downloadFile(remoteFileName: String, destFile: File): Boolean = withContext(Dispatchers.IO) {
val remote = buildRemoteUrl(remoteFileName)
try { try {
val cl = client ?: return@withContext false val cl = client ?: return@withContext false
val remote = buildRemoteUrl(remoteRelativePath)
val req = applyAuth(Request.Builder().url(remote).get()).build() val req = applyAuth(Request.Builder().url(remote).get()).build()
cl.newCall(req).execute().use { resp -> cl.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
Log.e(AppConfig.TAG, "WebDAV download failed: $remoteRelativePath (HTTP ${resp.code})") LogUtil.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
return@withContext false return@withContext false
} }
@@ -104,29 +104,31 @@ object WebDavManager {
} }
} }
Log.i(AppConfig.TAG, "WebDAV download success: $remoteRelativePath") LogUtil.i(AppConfig.TAG, "WebDAV download success: $remote")
return@withContext true return@withContext true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV download exception: $remoteRelativePath", e) LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
return@withContext false return@withContext false
} }
} }
/** /**
* Build a full remote URL by combining the configured base URL, the configured * Build a full remote URL by combining the configured base URL, the configured
* remote base path and a relative path provided by the caller. * remote base path and a file name provided by the caller.
* *
* Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups", * Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups",
* remoteRelativePath="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip" * remoteFileName="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip"
* *
* @param remoteRelativePath A path relative to the configured remoteBasePath (no leading slash required). * @param remoteFileName A file name relative to the configured remoteBasePath (no leading slash required).
* @return Full URL string used for HTTP operations. * @return Full URL string used for HTTP operations.
*/ */
private fun buildRemoteUrl(remoteRelativePath: String): String { private fun buildRemoteUrl(remoteFileName: String): String {
val base = cfg?.baseUrl?.trimEnd('/') ?: "" val base = cfg?.baseUrl?.trimEnd('/') ?: ""
val basePath = cfg?.remoteBasePath?.trim('/') ?: "" // Use configured remoteBasePath when not empty; otherwise fallback to AppConfig.WEBDAV_BACKUP_DIR
val rel = remoteRelativePath.trimStart('/') val basePathConfigured = cfg?.remoteBasePath?.trim('/')?.takeIf { it.isNotEmpty() }
val basePath = basePathConfigured ?: AppConfig.WEBDAV_BACKUP_DIR
val rel = remoteFileName.trimStart('/')
return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel" return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel"
} }
@@ -167,15 +169,15 @@ object WebDavManager {
cl.newCall(req).execute().use { resp -> cl.newCall(req).execute().use { resp ->
// 201 Created or 405 Method Not Allowed (already exists) are acceptable // 201 Created or 405 Method Not Allowed (already exists) are acceptable
if (resp.code != 201 && resp.code != 405 && resp.code != 409) { 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 (ignored: Exception) { } catch (_: Exception) {
// best-effort, continue // best-effort, continue
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e) LogUtil.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
} }
} }
} }
@@ -0,0 +1,88 @@
package com.v2ray.ang.helper
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
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.
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
*/
class FileChooserHelper(private val activity: AppCompatActivity) {
private var fileChooserCallback: ((Uri?) -> Unit)? = null
private var documentCreateCallback: ((Uri?) -> Unit)? = null
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
fileChooserCallback?.invoke(uri)
} else {
fileChooserCallback?.invoke(null)
}
fileChooserCallback = null
}
private val documentCreateLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
documentCreateCallback?.invoke(uri)
documentCreateCallback = null
}
/**
* Launch file chooser with ACTION_GET_CONTENT intent to select an existing file.
*
* @param mimeType MIME type filter for files
* @param onResult Callback invoked with the selected file URI (null if cancelled)
*/
fun launch(
mimeType: String = "*/*",
onResult: (Uri?) -> Unit
) {
fileChooserCallback = onResult
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
}
try {
fileChooserLauncher.launch(
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
)
} catch (ex: ActivityNotFoundException) {
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
fileChooserCallback?.invoke(null)
fileChooserCallback = null
}
}
/**
* Launch document creator to create a new file at user-selected location.
*
* @param fileName Default file name for the new document
* @param onResult Callback invoked with the created file URI (null if cancelled)
*/
fun createDocument(
fileName: String,
onResult: (Uri?) -> Unit
) {
documentCreateCallback = onResult
try {
documentCreateLauncher.launch(fileName)
} catch (ex: ActivityNotFoundException) {
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
documentCreateCallback?.invoke(null)
documentCreateCallback = null
}
}
}
@@ -1,7 +1,11 @@
package com.v2ray.ang.handler package com.v2ray.ang.helper
import androidx.preference.PreferenceDataStore import androidx.preference.PreferenceDataStore
import com.v2ray.ang.AppConfig 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. * PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
@@ -70,11 +74,16 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
// Internal helper: notify other modules about setting changes // Internal helper: notify other modules about setting changes
private fun notifySettingChanged(key: String) { private fun notifySettingChanged(key: String) {
if (key == AppConfig.PREF_LOGLEVEL) {
LogUtil.refreshLogLevel()
}
// Call SettingsManager.setNightMode if UI mode changed // Call SettingsManager.setNightMode if UI mode changed
if (key == AppConfig.PREF_UI_MODE_NIGHT) { if (key == AppConfig.PREF_UI_MODE_NIGHT) {
SettingsManager.setNightMode() SettingsManager.setNightMode()
} }
// Notify listeners that require service restart or reinit // Notify listeners that require service restart or reinit
SettingsChangeManager.makeRestartService() SettingsChangeManager.makeRestartService()
SettingsChangeManager.makeSetupGroupTab()
} }
} }
@@ -0,0 +1,46 @@
package com.v2ray.ang.helper
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast
/**
* Helper for requesting permissions.
*/
class PermissionHelper(private val activity: AppCompatActivity) {
private var permissionCallback: ((Boolean) -> Unit)? = null
private val permissionLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
permissionCallback?.invoke(isGranted)
permissionCallback = null
}
/**
* Check the permission and request it if not granted.
*
* @param permissionType the type of permission
* @param onGranted called when permission is granted (called immediately if already granted)
*/
fun request(permissionType: PermissionType, onGranted: () -> Unit) {
val permission = permissionType.getPermission()
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
onGranted()
} else {
permissionCallback = { isGranted ->
if (isGranted) {
onGranted()
} else {
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
activity.toast(message)
}
}
permissionLauncher.launch(permission)
}
}
}
@@ -0,0 +1,38 @@
package com.v2ray.ang.helper
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.ui.ScannerActivity
/**
* Helper for scanning QR codes.
*
* This class encapsulates the logic for launching the QR code scanner activity
* and handling the scan result.
*/
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
private var scanCallback: ((String?) -> Unit)? = null
private val scanLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
scanCallback?.invoke(scanResult)
} else {
scanCallback?.invoke(null)
}
scanCallback = null
}
/**
* Launch the QR code scanner activity.
*
* @param onResult Callback invoked with the scan result (null if cancelled or failed)
*/
fun launch(onResult: (String?) -> Unit) {
scanCallback = onResult
scanLauncher.launch(Intent(activity, ScannerActivity::class.java))
}
}
@@ -1,32 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.pm.ResolveInfo
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
init {
check(resolveInfo.providerInfo != null)
}
override val componentInfo get() = resolveInfo.providerInfo!!
}
@@ -1,43 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.graphics.drawable.Drawable
abstract class Plugin {
abstract val id: String
abstract val label: CharSequence
abstract val version: Int
abstract val versionName: String
open val icon: Drawable? get() = null
open val defaultConfig: String? get() = null
open val packageName: String get() = ""
open val directBootAware: Boolean get() = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return id == (other as Plugin).id
}
override fun hashCode() = id.hashCode()
}
@@ -1,33 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
object PluginContract {
const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN"
const val EXTRA_ENTRY = "io.nekohasekai.sagernet.plugin.EXTRA_ENTRY"
const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id"
const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path"
const val METHOD_GET_EXECUTABLE = "sagernet:getExecutable"
const val COLUMN_PATH = "path"
const val COLUMN_MODE = "mode"
const val SCHEME = "plugin"
}
@@ -1,54 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.Intent
import android.content.pm.PackageManager
import com.v2ray.ang.AngApplication
class PluginList : ArrayList<Plugin>() {
init {
addAll(
AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
)
.filter { it.providerInfo.exported }.map { NativePlugin(it) })
}
val lookup = mutableMapOf<String, Plugin>().apply {
for (plugin in this@PluginList.toList()) {
fun check(old: Plugin?) {
if (old != null && old != plugin) {
this@PluginList.remove(old)
}
/* if (old != null && old !== plugin) {
val packages = this@PluginList.filter { it.id == plugin.id }
.joinToString { it.packageName }
val message = "Conflicting plugins found from: $packages"
Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show()
throw IllegalStateException(message)
}*/
}
check(put(plugin.id, plugin))
}
}
}
@@ -1,232 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-AngApplication@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.system.Os
import androidx.core.os.bundleOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.extension.listenForPackageChanges
import com.v2ray.ang.extension.toast
import com.v2ray.ang.plugin.PluginContract.METADATA_KEY_ID
import java.io.File
import java.io.FileNotFoundException
object PluginManager {
class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin)
private var receiver: BroadcastReceiver? = null
private var cachedPlugins: PluginList? = null
fun fetchPlugins() = synchronized(this) {
if (receiver == null) receiver = AngApplication.application.listenForPackageChanges {
synchronized(this) {
receiver = null
cachedPlugins = null
}
}
if (cachedPlugins == null) cachedPlugins = PluginList()
cachedPlugins!!
}
private fun buildUri(id: String, authority: String) = Uri.Builder()
.scheme(PluginContract.SCHEME)
.authority(authority)
.path("/$id")
.build()
data class InitResult(
val path: String,
)
@Throws(Throwable::class)
fun init(pluginId: String): InitResult? {
if (pluginId.isEmpty()) return null
var throwable: Throwable? = null
try {
val result = initNative(pluginId)
if (result != null) return result
} catch (t: Throwable) {
if (throwable == null) throwable = t //Logs.w(t)
}
throw throwable ?: PluginNotFoundException(pluginId)
}
private fun initNative(pluginId: String): InitResult? {
var flags = PackageManager.GET_META_DATA
if (Build.VERSION.SDK_INT >= 24) {
flags =
flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
}
var providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "com.github.dyhkwong.AngApplication")), flags
)
.filter { it.providerInfo.exported }
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.AngApplication")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "fr.husi")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
).filter {
it.providerInfo.exported &&
it.providerInfo.metaData.containsKey(METADATA_KEY_ID) &&
it.providerInfo.metaData.getString(METADATA_KEY_ID) == pluginId
}
if (providers.size > 1) {
providers = listOf(providers[0]) // What if there is more than one?
}
}
if (providers.isEmpty()) return null
if (providers.size > 1) {
val message =
"Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
AngApplication.application.toast(message)
throw IllegalStateException(message)
}
val provider = providers.single().providerInfo
var failure: Throwable? = null
try {
initNativeFaster(provider)?.also { return InitResult(it) }
} catch (t: Throwable) {
// Logs.w("Initializing native plugin faster mode failed")
failure = t
}
val uri = Uri.Builder().apply {
scheme(ContentResolver.SCHEME_CONTENT)
authority(provider.authority)
}.build()
try {
return initNativeFast(
AngApplication.application.contentResolver,
pluginId,
uri
)?.let { InitResult(it) }
} catch (t: Throwable) {
// Logs.w("Initializing native plugin fast mode failed")
failure?.also { t.addSuppressed(it) }
failure = t
}
try {
return initNativeSlow(
AngApplication.application.contentResolver,
pluginId,
uri
)?.let { InitResult(it) }
} catch (t: Throwable) {
failure.also { t.addSuppressed(it) }
throw t
}
}
private fun initNativeFaster(provider: ProviderInfo): String? {
return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)
?.let { relativePath ->
File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
check(canExecute())
}.absolutePath
}
}
private fun initNativeFast(cr: ContentResolver, pluginId: String, uri: Uri): String? {
return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf())
?.getString(PluginContract.EXTRA_ENTRY)?.also {
check(File(it).canExecute())
}
}
@SuppressLint("Recycle")
private fun initNativeSlow(cr: ContentResolver, pluginId: String, uri: Uri): String? {
var initialized = false
fun entryNotFound(): Nothing =
throw IndexOutOfBoundsException("Plugin entry binary not found")
val pluginDir = File(AngApplication.application.noBackupFilesDir, "plugin")
(cr.query(
uri,
arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE),
null,
null,
null
)
?: return null).use { cursor ->
if (!cursor.moveToFirst()) entryNotFound()
pluginDir.deleteRecursively()
if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory")
val pluginDirPath = pluginDir.absolutePath + '/'
do {
val path = cursor.getString(0)
val file = File(pluginDir, path)
check(file.absolutePath.startsWith(pluginDirPath))
cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream ->
file.outputStream().use { outStream -> inStream.copyTo(outStream) }
}
Os.chmod(
file.absolutePath, when (cursor.getType(1)) {
Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1)
Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8)
else -> throw IllegalArgumentException("File mode should be of type int")
}
)
if (path == pluginId) initialized = true
} while (cursor.moveToNext())
}
if (!initialized) entryNotFound()
return File(pluginDir, pluginId).absolutePath
}
fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) {
is String -> value
// is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
// .getString(value)
null -> null
}
}
@@ -1,51 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.os.Build
import com.v2ray.ang.AngApplication
import com.v2ray.ang.plugin.PluginManager.loadString
abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
protected abstract val componentInfo: ComponentInfo
override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
override val version by lazy {
getPackageInfo(componentInfo.packageName).versionCode
}
override val versionName: String by lazy {
getPackageInfo(componentInfo.packageName).versionName!!
}
override val label: CharSequence get() = resolveInfo.loadLabel(AngApplication.application.packageManager)
override val icon: Drawable get() = resolveInfo.loadIcon(AngApplication.application.packageManager)
override val packageName: String get() = componentInfo.packageName
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
fun getPackageInfo(packageName: String) = AngApplication.application.packageManager.getPackageInfo(
packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
)!!
}
@@ -3,8 +3,10 @@ package com.v2ray.ang.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
/** /**
@@ -16,8 +18,24 @@ class BootReceiver : BroadcastReceiver() {
* @param intent The Intent being received. * @param intent The Intent being received.
*/ */
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return LogUtil.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
LogUtil.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
return
}
if (!MmkvManager.decodeStartOnBoot()) {
LogUtil.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
return
}
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "BootReceiver: No server selected")
return
}
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
V2RayServiceManager.startVService(context) V2RayServiceManager.startVService(context)
} }
} }
@@ -6,6 +6,7 @@ import android.content.Intent
import android.text.TextUtils import android.text.TextUtils
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
class TaskerReceiver : BroadcastReceiver() { class TaskerReceiver : BroadcastReceiver() {
@@ -35,7 +36,7 @@ class TaskerReceiver : BroadcastReceiver() {
V2RayServiceManager.stopVService(context) V2RayServiceManager.stopVService(context)
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e) LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
} }
} }
} }
@@ -1,8 +1,8 @@
package com.v2ray.ang.service package com.v2ray.ang.service
import android.content.Context import android.content.Context
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -16,7 +16,7 @@ class ProcessService {
* @param cmd The command to run. * @param cmd The command to run.
*/ */
fun runProcess(context: Context, cmd: MutableList<String>) { fun runProcess(context: Context, cmd: MutableList<String>) {
Log.i(AppConfig.TAG, cmd.toString()) LogUtil.i(AppConfig.TAG, cmd.toString())
try { try {
val proBuilder = ProcessBuilder(cmd) val proBuilder = ProcessBuilder(cmd)
@@ -27,14 +27,14 @@ class ProcessService {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
Thread.sleep(50L) Thread.sleep(50L)
Log.i(AppConfig.TAG, "runProcess check") LogUtil.i(AppConfig.TAG, "runProcess check")
process?.waitFor() 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) { } 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() { fun stopProcess() {
try { try {
Log.i(AppConfig.TAG, "runProcess destroy") LogUtil.i(AppConfig.TAG, "runProcess destroy")
process?.destroy() process?.destroy()
} catch (e: Exception) { } 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.graphics.drawable.Icon
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference import java.lang.ref.SoftReference
@@ -63,7 +63,7 @@ class QSTileService : TileService() {
applicationContext.unregisterReceiver(mMsgReceive) applicationContext.unregisterReceiver(mMsgReceive)
mMsgReceive = null mMsgReceive = null
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unregister receiver", e) LogUtil.e(AppConfig.TAG, "Failed to unregister receiver", e)
} }
} }
@@ -0,0 +1,85 @@
package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
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
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
/**
* Worker that runs a batch of real-ping tests independently.
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
*/
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
private val onFinish: (status: String) -> Unit = {}
) {
private val job = SupervisorJob()
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
private val runningCount = AtomicInteger(0)
private val totalCount = AtomicInteger(0)
fun start() {
val jobs = guids.map { guid ->
totalCount.incrementAndGet()
scope.launch {
runningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
}
}
}
scope.launch {
try {
joinAll(*jobs.toTypedArray())
onFinish("0")
} catch (_: CancellationException) {
onFinish("-1")
} finally {
close()
}
}
}
fun cancel() {
job.cancel()
}
private fun close() {
try {
dispatcher.close()
} catch (_: Throwable) {
// ignore
}
}
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
@@ -2,10 +2,11 @@ package com.v2ray.ang.service
import android.content.Context import android.content.Context
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import java.io.File import java.io.File
/** /**
@@ -21,9 +22,11 @@ class TProxyService(
@JvmStatic @JvmStatic
@Suppress("FunctionName") @Suppress("FunctionName")
private external fun TProxyStartService(configPath: String, fd: Int) private external fun TProxyStartService(configPath: String, fd: Int)
@JvmStatic @JvmStatic
@Suppress("FunctionName") @Suppress("FunctionName")
private external fun TProxyStopService() private external fun TProxyStopService()
@JvmStatic @JvmStatic
@Suppress("FunctionName") @Suppress("FunctionName")
private external fun TProxyGetStats(): LongArray? private external fun TProxyGetStats(): LongArray?
@@ -37,32 +40,36 @@ class TProxyService(
* Starts the tun2socks process with the appropriate parameters. * Starts the tun2socks process with the appropriate parameters.
*/ */
override fun startTun2Socks() { override fun startTun2Socks() {
// Log.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI") // LogUtil.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
val configContent = buildConfig() val configContent = buildConfig()
val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply { val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
writeText(configContent) writeText(configContent)
} }
// Log.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}") // LogUtil.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
Log.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent") LogUtil.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
try { try {
// Log.i(AppConfig.TAG, "TProxyStartService...") // LogUtil.i(AppConfig.TAG, "TProxyStartService...")
TProxyStartService(configFile.absolutePath, vpnInterface.fd) TProxyStartService(configFile.absolutePath, vpnInterface.fd)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}") LogUtil.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
} }
} }
private fun buildConfig(): String { private fun buildConfig(): String {
val socksPort = SettingsManager.getSocksPort() val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig() val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
val escapedSocksUsername = socksUsername?.replace("'", "''")
val escapedSocksPassword = socksPassword?.replace("'", "''")
return buildString { return buildString {
appendLine("tunnel:") appendLine("tunnel:")
appendLine(" mtu: ${SettingsManager.getVpnMtu()}") appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
appendLine(" ipv4: ${vpnConfig.ipv4Client}") appendLine(" ipv4: ${vpnConfig.ipv4Client}")
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) { if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)) {
appendLine(" ipv6: '${vpnConfig.ipv6Client}'") appendLine(" ipv6: '${vpnConfig.ipv6Client}'")
} }
@@ -70,6 +77,10 @@ class TProxyService(
appendLine(" port: ${socksPort}") appendLine(" port: ${socksPort}")
appendLine(" address: ${AppConfig.LOOPBACK}") appendLine(" address: ${AppConfig.LOOPBACK}")
appendLine(" udp: 'udp'") appendLine(" udp: 'udp'")
if (escapedSocksUsername != null && escapedSocksPassword != null) {
appendLine(" username: '${escapedSocksUsername}'")
appendLine(" password: '${escapedSocksPassword}'")
}
// Read-write timeout settings // Read-write timeout settings
val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT
@@ -91,10 +102,10 @@ class TProxyService(
*/ */
override fun stopTun2Socks() { override fun stopTun2Socks() {
try { try {
Log.i(AppConfig.TAG, "TProxyStopService...") LogUtil.i(AppConfig.TAG, "TProxyStopService...")
TProxyStopService() TProxyStopService()
} catch (e: Exception) { } 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)
} }
} }
} }
@@ -4,8 +4,11 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper import com.v2ray.ang.util.MyContextWrapper
import java.lang.ref.SoftReference import java.lang.ref.SoftReference
@@ -15,6 +18,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/ */
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
V2RayServiceManager.serviceControl = SoftReference(this) V2RayServiceManager.serviceControl = SoftReference(this)
} }
@@ -26,6 +30,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
* @return The start mode. * @return The start mode.
*/ */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
V2RayServiceManager.startCoreLoop(null) V2RayServiceManager.startCoreLoop(null)
return START_STICKY return START_STICKY
} }
@@ -6,37 +6,17 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_SUCCESS import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.extension.serializable import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.PluginServiceManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayNativeManager import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
import kotlinx.coroutines.CancellationException import java.util.Collections
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
class V2RayTestService : Service() { class V2RayTestService : Service() {
private val realTestJob = SupervisorJob()
private val realDispatcher = Dispatchers.IO.limitedParallelism(
Runtime.getRuntime().availableProcessors() * 3
)
private val realTestScope = CoroutineScope(
realTestJob + realDispatcher + CoroutineName("RealTest")
)
// simple counter for currently running tasks // manage active batch workers so each batch is independent and cancellable
private val realTestRunningCount = AtomicInteger(0) private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val realTestCount = AtomicInteger(0)
/** /**
* Initializes the V2Ray environment. * Initializes the V2Ray environment.
@@ -60,7 +40,10 @@ class V2RayTestService : Service() {
*/ */
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
realTestJob.cancel() // cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
} }
/** /**
@@ -71,74 +54,36 @@ class V2RayTestService : Service() {
* @return The start mode. * @return The start mode.
*/ */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.getIntExtra("key", 0)) { val message = intent?.serializable<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
when (message.key) {
MSG_MEASURE_CONFIG -> { MSG_MEASURE_CONFIG -> {
val guidsList = intent.serializable<ArrayList<String>>("content") val guidsList = if (message.serverGuids.isNotEmpty()) {
if (guidsList != null && guidsList.isNotEmpty()) { message.serverGuids
startBatchRealPing(guidsList) } 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 -> { MSG_MEASURE_CONFIG_CANCEL -> {
realTestJob.cancelChildren() // cancel all running batch workers independently
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
} }
} }
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
/**
* Starts batch real ping tests.
* @param guidsList The list of GUIDs to test.
*/
private fun startBatchRealPing(guidsList: List<String>) {
val jobs = guidsList.map { guid ->
realTestCount.incrementAndGet()
realTestScope.launch {
realTestRunningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} finally {
val count = realTestCount.decrementAndGet()
val left = realTestRunningCount.decrementAndGet()
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
}
}
}
realTestScope.launch {
try {
joinAll(*jobs.toTypedArray())
notifyAllTasksCompleted("0")
} catch (_: CancellationException) {
notifyAllTasksCompleted("-1")
}
}
}
private fun notifyAllTasksCompleted(status: String) {
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
}
/**
* Starts the real ping test.
* @param guid The GUID of the configuration.
* @return The ping result.
*/
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
if (config.configType == EConfigType.HYSTERIA2) {
val delay = PluginServiceManager.realPingHy2(this, config)
return delay
} else {
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(this, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
} }
@@ -1,5 +1,6 @@
package com.v2ray.ang.service package com.v2ray.ang.service
import android.annotation.SuppressLint
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -13,19 +14,22 @@ import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.os.StrictMode import android.os.StrictMode
import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig import com.v2ray.ang.BuildConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference import java.lang.ref.SoftReference
@SuppressLint("VpnServicePolicy")
class V2RayVpnService : VpnService(), ServiceControl { class V2RayVpnService : VpnService(), ServiceControl {
private lateinit var mInterface: ParcelFileDescriptor private lateinit var mInterface: ParcelFileDescriptor
private var isRunning = false private var isRunning = false
@@ -70,13 +74,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build() val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy) StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this) V2RayServiceManager.serviceControl = SoftReference(this)
} }
override fun onRevoke() { override fun onRevoke() {
stopV2Ray() LogUtil.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
stopAllService()
} }
// override fun onLowMemory() { // override fun onLowMemory() {
@@ -86,10 +92,27 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
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() NotificationManager.cancelNotification()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
setupVpnService() setupVpnService()
startService() startService()
return START_STICKY return START_STICKY
@@ -101,15 +124,19 @@ class V2RayVpnService : VpnService(), ServiceControl {
} }
override fun startService() { override fun startService() {
if (mInterface == null) { if (!::mInterface.isInitialized) {
Log.e(AppConfig.TAG, "Failed to create VPN interface") LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
return
}
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
stopAllService()
return return
} }
V2RayServiceManager.startCoreLoop(mInterface)
} }
override fun stopService() { override fun stopService() {
stopV2Ray(true) stopAllService(true)
} }
override fun vpnProtect(socket: Int): Boolean { override fun vpnProtect(socket: Int): Boolean {
@@ -130,10 +157,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
private fun setupVpnService() { private fun setupVpnService() {
val prepare = prepare(this) val prepare = prepare(this)
if (prepare != null) { if (prepare != null) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
stopSelf()
return return
} }
if (configureVpnService() != true) { if (configureVpnService() != true) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
stopSelf()
return return
} }
@@ -155,9 +186,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
// Close the old interface since the parameters have been changed // Close the old interface since the parameters have been changed
try { try {
mInterface.close() if (::mInterface.isInitialized) {
} catch (ignored: Exception) { mInterface.close()
// ignored }
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "Failed to close old interface", e)
} }
// Configure platform-specific features // Configure platform-specific features
@@ -169,8 +202,8 @@ class V2RayVpnService : VpnService(), ServiceControl {
isRunning = true isRunning = true
return true return true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e) LogUtil.e(AppConfig.TAG, "Failed to establish VPN interface", e)
stopV2Ray() stopAllService()
} }
return false return false
} }
@@ -200,7 +233,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
} }
// Configure IPv6 if enabled // 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) builder.addAddress(vpnConfig.ipv6Client, 126)
if (bypassLan) { if (bypassLan) {
builder.addRoute("2000::", 3) // Currently only 1/8 of total IPv6 is in use builder.addRoute("2000::", 3) // Currently only 1/8 of total IPv6 is in use
@@ -234,7 +267,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
try { try {
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback) connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to request default network", e) LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
} }
} }
@@ -287,7 +320,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
builder.addAllowedApplication(it) builder.addAllowedApplication(it)
} }
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {
Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e) LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
} }
} }
} }
@@ -311,11 +344,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
tun2SocksService?.startTun2Socks() tun2SocksService?.startTun2Socks()
} }
/** private fun stopAllService(isForced: Boolean = true) {
* Stops the V2Ray service.
* @param isForced Whether to force stop the service.
*/
private fun stopV2Ray(isForced: Boolean = true) {
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "") // val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
// val emptyInfo = VpnNetworkInfo() // val emptyInfo = VpnNetworkInfo()
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo) // val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
@@ -324,8 +353,8 @@ class V2RayVpnService : VpnService(), ServiceControl {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try { try {
connectivity.unregisterNetworkCallback(defaultNetworkCallback) connectivity.unregisterNetworkCallback(defaultNetworkCallback)
} catch (ignored: Exception) { } catch (e: Exception) {
// ignored LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
} }
} }
@@ -342,10 +371,22 @@ class V2RayVpnService : VpnService(), ServiceControl {
//which means the first v2ray core somehow failed to stop and release the port. //which means the first v2ray core somehow failed to stop and release the port.
stopSelf() 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 { try {
mInterface.close() 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) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to close VPN interface", e) LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
} }
} }
} }
@@ -1,30 +1,25 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.app.AlertDialog import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WEBDAV_BACKUP_FILE_NAME
import com.v2ray.ang.BuildConfig import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBackupBinding import com.v2ray.ang.databinding.ActivityBackupBinding
import com.v2ray.ang.databinding.DialogWebdavBinding import com.v2ray.ang.databinding.DialogWebdavBinding
import com.v2ray.ang.dto.WebDavConfig import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.WebDavManager import com.v2ray.ang.handler.WebDavManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.ZipUtil import com.v2ray.ang.util.ZipUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -33,58 +28,17 @@ import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
class BackupActivity : BaseActivity() { class BackupActivity : HelperBaseActivity() {
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) } private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
private val config_backup_options: Array<out String> by lazy { private val config_backup_options: Array<out String> by lazy {
resources.getStringArray(R.array.config_backup_options) resources.getStringArray(R.array.config_backup_options)
} }
companion object {
private const val BACKUP_FILE_NAME = "backup_ng.zip"
}
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
}
} else {
toast(R.string.toast_permission_denied)
}
}
private val createBackupFile =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
if (uri != null) {
try {
val ret = backupConfigurationToCache()
if (ret.first) {
// Copy the cached zip file to user-selected location
contentResolver.openOutputStream(uri)?.use { output ->
File(ret.second).inputStream().use { input ->
input.copyTo(output)
}
}
// Clean up cache file
File(ret.second).delete()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
binding.layoutBackup.setOnClickListener { binding.layoutBackup.setOnClickListener {
@@ -171,46 +125,35 @@ class BackupActivity : BaseActivity() {
val count = MMKV.restoreAllFromDirectory(backupDir) val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeSetupGroupTab() SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService() SettingsChangeManager.makeRestartService()
SettingsManager.initApp(this)
return count > 0 return count > 0
} }
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply { launchFileChooser { uri ->
type = "*/*" if (uri == null) {
addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
} }
try {
try { val targetFile =
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser))) File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
} catch (ex: ActivityNotFoundException) { contentResolver.openInputStream(uri).use { input ->
Log.e(AppConfig.TAG, "File chooser activity not found", ex) targetFile.outputStream().use { fileOut ->
toast(R.string.toast_require_file_manager) input?.copyTo(fileOut)
}
}
private val chooseFile =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == RESULT_OK && uri != null) {
try {
val targetFile =
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
contentResolver.openInputStream(uri).use { input ->
targetFile.outputStream().use { fileOut ->
input?.copyTo(fileOut)
}
} }
if (restoreConfiguration(targetFile)) { }
toastSuccess(R.string.toast_success) if (restoreConfiguration(targetFile)) {
} else { toastSuccess(R.string.toast_success)
toastError(R.string.toast_failure) } else {
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure) toastError(R.string.toast_failure)
} }
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
} }
} }
}
private fun backupViaLocal() { private fun backupViaLocal() {
val dateFormatted = SimpleDateFormat( val dateFormatted = SimpleDateFormat(
@@ -218,25 +161,34 @@ class BackupActivity : BaseActivity() {
Locale.getDefault() Locale.getDefault()
).format(System.currentTimeMillis()) ).format(System.currentTimeMillis())
val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip" val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
createBackupFile.launch(defaultFileName)
launchCreateDocument(defaultFileName) { uri ->
if (uri != null) {
try {
val ret = backupConfigurationToCache()
if (ret.first) {
// Copy the cached zip file to user-selected location
contentResolver.openOutputStream(uri)?.use { output ->
File(ret.second).inputStream().use { input ->
input.copyTo(output)
}
}
// Clean up cache file
File(ret.second).delete()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to backup configuration", e)
toastError(R.string.toast_failure)
}
}
}
} }
private fun restoreViaLocal() { private fun restoreViaLocal() {
val permission = showFileChooser()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
}
} else {
requestPermissionLauncher.launch(permission)
}
} }
private fun backupViaWebDav() { private fun backupViaWebDav() {
@@ -263,9 +215,9 @@ class BackupActivity : BaseActivity() {
WebDavManager.init(saved) WebDavManager.init(saved)
val ok = try { val ok = try {
WebDavManager.uploadFile(tempFile, BACKUP_FILE_NAME) WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload error", e) LogUtil.e(AppConfig.TAG, "WebDAV upload error", e)
false false
} }
@@ -273,7 +225,7 @@ class BackupActivity : BaseActivity() {
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure) if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV backup error", e) LogUtil.e(AppConfig.TAG, "WebDAV backup error", e)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
toastError(R.string.toast_failure) toastError(R.string.toast_failure)
} }
@@ -303,7 +255,7 @@ class BackupActivity : BaseActivity() {
try { try {
target = File(cacheDir, "download_${System.currentTimeMillis()}.zip") target = File(cacheDir, "download_${System.currentTimeMillis()}.zip")
WebDavManager.init(saved) WebDavManager.init(saved)
val ok = WebDavManager.downloadFile(BACKUP_FILE_NAME, target) val ok = WebDavManager.downloadFile(WEBDAV_BACKUP_FILE_NAME, target)
if (!ok) { if (!ok) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
toastError(R.string.toast_failure) toastError(R.string.toast_failure)
@@ -320,7 +272,7 @@ class BackupActivity : BaseActivity() {
} }
} }
} catch (e: Exception) { } 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) } withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
} finally { } finally {
try { try {
@@ -351,7 +303,7 @@ class BackupActivity : BaseActivity() {
val url = dialogBinding.etWebdavUrl.text.toString().trim() val url = dialogBinding.etWebdavUrl.text.toString().trim()
val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null } val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
val pass = dialogBinding.etWebdavPass.text.toString() val pass = dialogBinding.etWebdavPass.text.toString()
val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { "/" } val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath) val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
MmkvManager.encodeWebDavConfig(cfg) MmkvManager.encodeWebDavConfig(cfg)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
@@ -39,6 +39,7 @@ abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (!Utils.getDarkModeStatus(this)) { if (!Utils.getDarkModeStatus(this)) {
WindowCompat.getInsetsController(window, window.decorView).apply { WindowCompat.getInsetsController(window, window.decorView).apply {
@@ -212,5 +213,4 @@ abstract class BaseActivity : AppCompatActivity() {
protected fun isLoadingVisible(): Boolean { protected fun isLoadingVisible(): Boolean {
return progressBar?.visibility == View.VISIBLE return progressBar?.visibility == View.VISIBLE
} }
} }
@@ -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 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). * @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 // Get the drawable from resources
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId) val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
requireNotNull(drawable) { "Drawable resource not found" } requireNotNull(drawable) { "Drawable resource not found" }
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
@@ -15,6 +14,7 @@ import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.UpdateCheckerManager import com.v2ray.ang.handler.UpdateCheckerManager
import com.v2ray.ang.handler.V2RayNativeManager import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -56,10 +56,9 @@ class CheckUpdateActivity : BaseActivity() {
toastSuccess(R.string.update_already_latest_version) toastSuccess(R.string.update_already_latest_version)
} }
} catch (e: Exception) { } 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)) toastError(e.message ?: getString(R.string.toast_failure))
} } finally {
finally {
hideLoading() hideLoading()
} }
} }
@@ -11,6 +11,8 @@ import com.v2ray.ang.dto.GroupMapItem
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) { class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = groups.size override fun getItemCount(): Int = groups.size
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id) override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun update(groups: List<GroupMapItem>) { fun update(groups: List<GroupMapItem>) {
@@ -1,27 +1,50 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.TAG
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.FragmentGroupServerBinding import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
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.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.MainViewModel import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() { class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
SwipeRefreshLayout.OnRefreshListener {
private val ownerActivity: MainActivity
get() = requireActivity() as MainActivity
private val mainViewModel: MainViewModel by activityViewModels() private val mainViewModel: MainViewModel by activityViewModels()
private lateinit var adapter: MainRecyclerAdapter private lateinit var adapter: MainRecyclerAdapter
private var itemTouchHelper: ItemTouchHelper? = null private var itemTouchHelper: ItemTouchHelper? = null
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() } private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
private val share_method: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method_more)
}
companion object { companion object {
private const val ARG_SUB_ID = "subscriptionId" private const val ARG_SUB_ID = "subscriptionId"
fun newInstance(subId: String) = GroupServerFragment().apply { fun newInstance(subId: String) = GroupServerFragment().apply {
@@ -34,7 +57,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = MainRecyclerAdapter(requireActivity() as MainActivity) adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) { if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2) binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
@@ -47,22 +70,251 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false)) itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
itemTouchHelper?.attachToRecyclerView(binding.recyclerView) itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
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 -> mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId != subId) { if (mainViewModel.subscriptionId != subId) {
return@observe return@observe
} }
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId") // LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index) adapter.setData(mainViewModel.serversCache, index)
} }
mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
adapter.isRunning = isRunning
}
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId") // LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
mainViewModel.subscriptionIdChanged(subId) mainViewModel.subscriptionIdChanged(subId)
} }
}
/**
* Shares server configuration
* Displays a dialog with sharing options and executes the selected action
* @param guid The server unique identifier
* @param profile The server configuration
* @param position The position in the list
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(ownerActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
if (share_method.isNotEmpty()) {
ivBinding.ivQcode.contentDescription = share_method[0]
} else {
ivBinding.ivQcode.contentDescription = "QR Code"
}
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
/**
* Shares full server configuration content to clipboard
* @param guid The server unique identifier
*/
private fun shareFullContent(guid: String) {
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val intent = Intent().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))
}
}
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid == MmkvManager.getSelectServer()) {
ownerActivity.toast(R.string.toast_action_not_allowed)
return
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
}
/**
* Executes the actual server removal process
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position)
}
/**
* Sets the selected server
* Updates UI and restarts service if needed
* @param guid The server unique identifier to select
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
val toPosition = mainViewModel.getPosition(guid)
adapter.setSelectServer(fromPosition, toPosition)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
}
private inner class ActivityAdapterListener : MainAdapterListener {
override fun onEdit(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
}
override fun onRemove(guid: String, position: Int) {
removeServer(guid, position)
}
override fun onEdit(guid: String, position: Int, profile: ProfileItem) {
editServer(guid, profile)
}
override fun onSelectServer(guid: String) {
setSelectServer(guid)
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
options to if (isCustom) 2 else 0
} else {
val options = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
options to if (isCustom) 2 else 0
}
shareServer(guid, profile, position, shareOptions, skip)
}
}
override fun onRefresh() {
ownerActivity.importConfigViaSub()
//binding.refreshLayout.isRefreshing = false
}
/**
* Scrolls to the currently selected server in the RecyclerView
*/
fun scrollToSelectedServer() {
val selectedGuid = MmkvManager.getSelectServer()
if (selectedGuid.isNullOrEmpty()) {
ownerActivity.toast(R.string.title_file_chooser)
return
}
// Find the position of the selected server
val serversCache = mainViewModel.serversCache
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
val recyclerView = binding.recyclerView
if (position >= 0) {
// Get the layout manager
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
if (layoutManager != null) {
// Scroll to position with offset to center it on screen
// First scroll to position, then adjust to center
recyclerView.post {
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
}
} else {
// Fallback to smooth scroll if layout manager is not GridLayoutManager
recyclerView.smoothScrollToPosition(position)
}
} else {
ownerActivity.toast(R.string.toast_server_not_found_in_group)
}
}
}
@@ -0,0 +1,90 @@
package com.v2ray.ang.ui
import android.net.Uri
import android.os.Bundle
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.helper.FileChooserHelper
import com.v2ray.ang.helper.PermissionHelper
import com.v2ray.ang.helper.QRCodeScannerHelper
/**
* HelperBaseActivity extends BaseActivity and provides additional helpers for
* activities that need file chooser, permission requesting, or QR code scanning functionality.
*
* Activities that don't need these features should extend BaseActivity directly.
* Activities that need file selection, permissions, or QR code scanning should extend this class.
*
* Additional Responsibilities:
* - Provide file chooser helpers for selecting and creating files.
* - Provide permission request helpers with callbacks.
* - 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
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fileChooser = FileChooserHelper(this)
permissionRequester = PermissionHelper(this)
qrCodeScanner = QRCodeScannerHelper(this)
}
/**
* Check if permission is granted and request it if not.
* Convenience method that delegates to permissionRequester.
*
* @param permissionType The type of permission to check and request
* @param onGranted Callback to execute when permission is granted
*/
protected fun checkAndRequestPermission(
permissionType: PermissionType,
onGranted: () -> Unit
) {
permissionRequester.request(permissionType, onGranted)
}
/**
* Launch file chooser with ACTION_GET_CONTENT intent.
* Convenience method that delegates to fileChooser helper.
*
* @param mimeType MIME type filter for files
* @param onResult Callback invoked with the selected file URI (null if cancelled)
*/
protected fun launchFileChooser(
mimeType: String = "*/*",
onResult: (Uri?) -> Unit
) {
checkAndRequestPermission(PermissionType.READ_STORAGE) {
fileChooser.launch(mimeType, onResult)
}
}
/**
* Launch document creator to create a new file at user-selected location.
* Convenience method that delegates to fileChooser helper.
* Note: No permission check needed as CreateDocument uses Storage Access Framework.
*
* @param fileName Default file name for the new document
* @param onResult Callback invoked with the created file URI (null if cancelled)
*/
protected fun launchCreateDocument(
fileName: String,
onResult: (Uri?) -> Unit
) {
fileChooser.createDocument(fileName, onResult)
}
/**
* Launch QR code scanner with camera permission check.
* Convenience method that delegates to qrCodeScanner helper.
*
* @param onResult Callback invoked with the scan result string (null if cancelled or failed)
*/
protected fun launchQRCodeScanner(onResult: (String?) -> Unit) {
checkAndRequestPermission(PermissionType.CAMERA) {
qrCodeScanner.launch(onResult)
}
}
}
@@ -2,37 +2,34 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.LogcatViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener { class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) } private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
private val viewModel: LogcatViewModel by viewModels()
private var logsetsAll: MutableList<String> = mutableListOf() private lateinit var adapter: LogcatRecyclerAdapter
var logsets: MutableList<String> = mutableListOf()
private val adapter by lazy { LogcatRecyclerAdapter(this) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
@@ -40,58 +37,12 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
binding.refreshLayout.setOnRefreshListener(this) binding.refreshLayout.setOnRefreshListener(this)
logsets.add(getString(R.string.pull_down_to_refresh)) toast(getString(R.string.pull_down_to_refresh))
} }
private fun getLogcat() { private fun onLogLongClick(log: String): Boolean {
Utils.setClipboard(this, log)
try { return true
binding.refreshLayout.isRefreshing = true
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-d")
lst.add("-v")
lst.add("time")
lst.add("-s")
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
val process = withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(lst.toTypedArray())
}
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
launch(Dispatchers.Main) {
logsetsAll = allText.toMutableList()
logsets = allText.toMutableList()
refreshData()
binding.refreshLayout.isRefreshing = false
}
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
private fun clearLogcat() {
try {
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-c")
withContext(Dispatchers.IO) {
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
}
launch(Dispatchers.Main) {
logsetsAll.clear()
logsets.clear()
refreshData()
}
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to clear logcat", e)
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -104,12 +55,14 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
override fun onQueryTextSubmit(query: String?): Boolean = false override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
filterLogs(newText) viewModel.filter(newText)
refreshData()
return false return false
} }
}) })
searchView.setOnCloseListener { searchView.setOnCloseListener {
filterLogs("") viewModel.filter("")
refreshData()
false false
} }
} }
@@ -119,33 +72,33 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.copy_all -> { R.id.copy_all -> {
Utils.setClipboard(this, logsets.joinToString("\n")) val all = viewModel.getAll().joinToString("\n")
Utils.setClipboard(this, all)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
true true
} }
R.id.clear_all -> { R.id.clear_all -> {
clearLogcat() lifecycleScope.launch(Dispatchers.IO) {
viewModel.clearLogcat()
withContext(Dispatchers.Main) {
refreshData()
}
}
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
private fun filterLogs(content: String?): Boolean {
val key = content?.trim()
logsets = if (key.isNullOrEmpty()) {
logsetsAll.toMutableList()
} else {
logsetsAll.filter { it.contains(key) }.toMutableList()
}
refreshData()
return true
}
override fun onRefresh() { override fun onRefresh() {
getLogcat() lifecycleScope.launch(Dispatchers.IO) {
viewModel.loadLogcat()
withContext(Dispatchers.Main) {
binding.refreshLayout.isRefreshing = false
refreshData()
}
}
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
@@ -1,22 +1,25 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.LogcatViewModel
class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() { class LogcatRecyclerAdapter(
private var mActivity: LogcatActivity = activity private val viewModel: LogcatViewModel,
private val onLongClick: ((String) -> Boolean)? = null
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
override fun getItemCount() = viewModel.getAll().size
override fun getItemCount() = mActivity.logsets.size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
try { try {
val log = mActivity.logsets[position] val logs = viewModel.getAll()
val log = logs[position]
if (log.isEmpty()) { if (log.isEmpty()) {
holder.itemSubSettingBinding.logTag.text = "" holder.itemSubSettingBinding.logTag.text = ""
holder.itemSubSettingBinding.logContent.text = "" holder.itemSubSettingBinding.logContent.text = ""
@@ -27,11 +30,10 @@ class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter
} }
holder.itemView.setOnLongClickListener { holder.itemView.setOnLongClickListener {
Utils.setClipboard(mActivity, log) onLongClick?.invoke(log) ?: false
true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Error binding log view data", e) LogUtil.e(AppConfig.TAG, "Error binding log view data", e)
} }
} }
@@ -1,14 +1,10 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.net.Uri import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
@@ -25,16 +21,18 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityMainBinding import com.v2ray.ang.databinding.ActivityMainBinding
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,7 +40,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener { class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
private val binding by lazy { private val binding by lazy {
ActivityMainBinding.inflate(layoutInflater) ActivityMainBinding.inflate(layoutInflater)
} }
@@ -65,102 +63,24 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
// register activity result for requesting permission
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
when (pendingAction) {
Action.IMPORT_QR_CODE_CONFIG ->
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
Action.READ_CONTENT_FROM_URI ->
chooseFileForCustomConfig.launch(Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
}, getString(R.string.title_file_chooser)))
Action.POST_NOTIFICATIONS -> {}
else -> {}
}
} else {
toast(R.string.toast_permission_denied)
}
pendingAction = Action.NONE
}
private var pendingAction: Action = Action.NONE
enum class Action {
NONE,
IMPORT_QR_CODE_CONFIG,
READ_CONTENT_FROM_URI,
POST_NOTIFICATIONS
}
private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
readContentFromUri(uri)
}
}
private val scanQRCodeForConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"))
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
setupToolbar(binding.toolbar,false, getString(R.string.title_server)) setupToolbar(binding.toolbar, false, getString(R.string.title_server))
binding.fab.setOnClickListener {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
val intent = VpnService.prepare(this)
if (intent == null) {
startV2Ray()
} else {
requestVpnPermission.launch(intent)
}
} else {
startV2Ray()
}
}
binding.layoutTest.setOnClickListener {
if (mainViewModel.isRunning.value == true) {
setTestState(getString(R.string.connection_test_testing))
mainViewModel.testCurrentServerRealPing()
} else {
// tv_test_state.text = getString(R.string.connection_test_fail)
}
}
// setup viewpager and tablayout
groupPagerAdapter = GroupPagerAdapter(this, emptyList()) groupPagerAdapter = GroupPagerAdapter(this, emptyList())
binding.viewPager.adapter = groupPagerAdapter binding.viewPager.adapter = groupPagerAdapter
binding.viewPager.isUserInputEnabled = true binding.viewPager.isUserInputEnabled = true
// setup navigation drawer
val toggle = ActionBarDrawerToggle( val toggle = ActionBarDrawerToggle(
this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
) )
binding.drawerLayout.addDrawerListener(toggle) binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState() toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this) binding.navView.setNavigationItemSelectedListener(this)
setupGroupTab()
setupViewModel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.POST_NOTIFICATIONS
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
@@ -172,25 +92,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
}) })
binding.fab.setOnClickListener { handleFabAction() }
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
setupGroupTab()
setupViewModel()
mainViewModel.reloadServerList() mainViewModel.reloadServerList()
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
}
} }
private fun setupViewModel() { private fun setupViewModel() {
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) } mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.isRunning.observe(this) { isRunning -> mainViewModel.isRunning.observe(this) { isRunning ->
if (isRunning) { applyRunningState(false, isRunning)
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
binding.layoutTest.isFocusable = false
}
} }
mainViewModel.startListenBroadcast() mainViewModel.startListenBroadcast()
mainViewModel.initAssets(assets) mainViewModel.initAssets(assets)
@@ -214,6 +131,32 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
binding.tabGroup.isVisible = groups.size > 1 binding.tabGroup.isVisible = groups.size > 1
} }
private fun handleFabAction() {
applyRunningState(isLoading = true, isRunning = false)
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
} else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this)
if (intent == null) {
startV2Ray()
} else {
requestVpnPermission.launch(intent)
}
} else {
startV2Ray()
}
}
private fun handleLayoutTestClick() {
if (mainViewModel.isRunning.value == true) {
setTestState(getString(R.string.connection_test_testing))
mainViewModel.testCurrentServerRealPing()
} else {
// service not running: keep existing no-op (could show a message if desired)
}
}
private fun startV2Ray() { private fun startV2Ray() {
if (MmkvManager.getSelectServer().isNullOrEmpty()) { if (MmkvManager.getSelectServer().isNullOrEmpty()) {
toast(R.string.title_file_chooser) toast(R.string.title_file_chooser)
@@ -232,6 +175,31 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
private fun setTestState(content: String?) {
binding.tvTestState.text = content
}
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
if (isLoading) {
binding.fab.setImageResource(R.drawable.ic_fab_check)
return
}
if (isRunning) {
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
binding.layoutTest.isFocusable = false
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
} }
@@ -371,6 +339,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
true true
} }
R.id.locate_selected_config -> {
locateSelectedServer()
true
}
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
@@ -396,12 +368,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* import config from qrcode * import config from qrcode
*/ */
private fun importQRcode(): Boolean { private fun importQRcode(): Boolean {
val permission = Manifest.permission.CAMERA launchQRCodeScanner { scanResult ->
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { if (scanResult != null) {
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java)) importBatchConfig(scanResult)
} else { }
pendingAction = Action.IMPORT_QR_CODE_CONFIG
requestPermissionLauncher.launch(permission)
} }
return true return true
} }
@@ -415,7 +385,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
val clipboard = Utils.getClipboard(this) val clipboard = Utils.getClipboard(this)
importBatchConfig(clipboard) importBatchConfig(clipboard)
} catch (e: Exception) { } 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 false
} }
return true return true
@@ -445,7 +415,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
toastError(R.string.toast_failure) toastError(R.string.toast_failure)
hideLoading() hideLoading()
} }
Log.e(AppConfig.TAG, "Failed to import batch config", e) LogUtil.e(AppConfig.TAG, "Failed to import batch config", e)
} }
} }
} }
@@ -457,7 +427,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
try { try {
showFileChooser() showFileChooser()
} catch (e: Exception) { } 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 false
} }
return true return true
@@ -467,18 +437,27 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
/** /**
* import config from sub * import config from sub
*/ */
private fun importConfigViaSub(): Boolean { fun importConfigViaSub(): Boolean {
showLoading() showLoading()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val count = mainViewModel.updateConfigViaSubAll() val result = mainViewModel.updateConfigViaSubAll()
delay(500L) delay(500L)
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
if (count > 0) { if (result.successCount + result.failureCount + result.skipCount == 0) {
toast(getString(R.string.title_update_config_count, count)) toast(R.string.title_update_subscription_no_subscription)
mainViewModel.reloadServerList() } else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
toast(getString(R.string.title_update_config_count, result.configCount))
} else { } else {
toastError(R.string.toast_failure) toast(
getString(
R.string.title_update_subscription_result,
result.configCount, result.successCount, result.failureCount, result.skipCount
)
)
}
if (result.configCount > 0) {
mainViewModel.reloadServerList()
} }
hideLoading() hideLoading()
} }
@@ -572,21 +551,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* show file chooser * show file chooser
*/ */
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT) launchFileChooser { uri ->
intent.type = "*/*" if (uri == null) {
intent.addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
}
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { readContentFromUri(uri)
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.READ_CONTENT_FROM_URI
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
} else {
requestPermissionLauncher.launch(permission)
} }
} }
@@ -594,37 +564,55 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
* read content from uri * read content from uri
*/ */
private fun readContentFromUri(uri: Uri) { private fun readContentFromUri(uri: Uri) {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { try {
Manifest.permission.READ_MEDIA_IMAGES contentResolver.openInputStream(uri).use { input ->
} else { importBatchConfig(input?.bufferedReader()?.readText())
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
try {
contentResolver.openInputStream(uri).use { input ->
importBatchConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
} }
} else { } catch (e: Exception) {
requestPermissionLauncher.launch(permission) LogUtil.e(AppConfig.TAG, "Failed to read content from URI", e)
} }
} }
private fun setTestState(content: String?) { /**
binding.tvTestState.text = content * Locates and scrolls to the currently selected server.
* If the selected server is in a different group, automatically switches to that group first.
*/
private fun locateSelectedServer() {
val targetSubscriptionId = mainViewModel.findSubscriptionIdBySelect()
if (targetSubscriptionId.isNullOrEmpty()) {
toast(R.string.title_file_chooser)
return
}
val targetGroupIndex = groupPagerAdapter.groups.indexOfFirst { it.id == targetSubscriptionId }
if (targetGroupIndex < 0) {
toast(R.string.toast_server_not_found_in_group)
return
}
// Switch to target group if needed, then scroll to the server
if (binding.viewPager.currentItem != targetGroupIndex) {
binding.viewPager.setCurrentItem(targetGroupIndex, true)
binding.viewPager.postDelayed({ scrollToSelectedServer(targetGroupIndex) }, 1000)
} else {
scrollToSelectedServer(targetGroupIndex)
}
} }
// val mConnection = object : ServiceConnection { /**
// override fun onServiceDisconnected(name: ComponentName?) { * Scrolls to the selected server in the specified fragment.
// } * @param groupIndex The index of the group/fragment to scroll in
// */
// override fun onServiceConnected(name: ComponentName?, service: IBinder?) { private fun scrollToSelectedServer(groupIndex: Int) {
// sendMsg(AppConfig.MSG_REGISTER_CLIENT, "") val itemId = groupPagerAdapter.getItemId(groupIndex)
// } val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment
// }
if (fragment?.isAdded == true && fragment.view != null) {
fragment.scrollToSelectedServer()
} else {
toast(R.string.toast_fragment_not_available)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) { if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
@@ -1,59 +1,41 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AngApplication.Companion.application
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemQrcodeBinding import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import kotlinx.coroutines.Dispatchers import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.launch import java.util.Collections
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter { class MainRecyclerAdapter(
private val mainViewModel: MainViewModel,
private val adapterListener: MainAdapterListener?
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
companion object { companion object {
private const val VIEW_TYPE_ITEM = 1 private const val VIEW_TYPE_ITEM = 1
private const val VIEW_TYPE_FOOTER = 2 private const val VIEW_TYPE_FOOTER = 2
} }
private var mActivity: MainActivity = activity
private val share_method: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_method_more)
}
var isRunning = false
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false) private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private var data: MutableList<ServersCache> = mutableListOf() private var data: MutableList<ServersCache> = mutableListOf()
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) { fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
mActivity.runOnUiThread { setData(newData, position) }
return
}
data = newData?.toMutableList() ?: mutableListOf() data = newData?.toMutableList() ?: mutableListOf()
if (position >= 0 && position in data.indices) { if (position >= 0 && position in data.indices) {
@@ -67,9 +49,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is MainViewHolder) { if (holder is MainViewHolder) {
val context = holder.itemMainBinding.root.context
val guid = data[position].guid val guid = data[position].guid
val profile = data[position].profile val profile = data[position].profile
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
@@ -82,9 +64,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
val aff = MmkvManager.decodeServerAffiliationInfo(guid) val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty() holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
if ((aff?.testDelayMillis ?: 0L) < 0L) { if ((aff?.testDelayMillis ?: 0L) < 0L) {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPingRed)) holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
} else { } else {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing)) holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
} }
//layoutIndicator //layoutIndicator
@@ -106,11 +88,8 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
holder.itemMainBinding.layoutRemove.visibility = View.GONE holder.itemMainBinding.layoutRemove.visibility = View.GONE
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
//share method
val shareOptions = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
holder.itemMainBinding.layoutMore.setOnClickListener { holder.itemMainBinding.layoutMore.setOnClickListener {
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0) adapterListener?.onShare(guid, profile, position, true)
} }
} else { } else {
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
@@ -118,34 +97,23 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
holder.itemMainBinding.layoutMore.visibility = View.GONE holder.itemMainBinding.layoutMore.visibility = View.GONE
//share method
val shareOptions = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
holder.itemMainBinding.layoutShare.setOnClickListener { holder.itemMainBinding.layoutShare.setOnClickListener {
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0) adapterListener?.onShare(guid, profile, position, false)
} }
holder.itemMainBinding.layoutEdit.setOnClickListener { holder.itemMainBinding.layoutEdit.setOnClickListener {
editServer(guid, profile) adapterListener?.onEdit(guid, position, profile)
} }
holder.itemMainBinding.layoutRemove.setOnClickListener { holder.itemMainBinding.layoutRemove.setOnClickListener {
removeServer(guid, position) adapterListener?.onRemove(guid, position)
} }
} }
holder.itemMainBinding.infoContainer.setOnClickListener { holder.itemMainBinding.infoContainer.setOnClickListener {
setSelectServer(guid) adapterListener?.onSelectServer(guid)
} }
} }
// if (holder is FooterViewHolder) {
// if (true) {
// holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
// } else {
// holder.itemFooterBinding.layoutEdit.setOnClickListener {
// Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
// }
// }
// }
} }
/** /**
@@ -155,19 +123,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
* @return Formatted address string * @return Formatted address string
*/ */
private fun getAddress(profile: ProfileItem): String { private fun getAddress(profile: ProfileItem): String {
// Hide xxx:xxx:***/xxx.xxx.xxx.*** return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
val server = profile.server
val port = profile.serverPort
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
val addrPart = server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
} ?: ""
return "$addrPart : ${port ?: ""}"
} }
/** /**
@@ -177,155 +133,25 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
*/ */
private fun getSubscriptionRemarks(profile: ProfileItem): String { private fun getSubscriptionRemarks(profile: ProfileItem): String {
val subRemarks = val subRemarks =
if (mActivity.mainViewModel.subscriptionId.isEmpty()) if (mainViewModel.subscriptionId.isEmpty())
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull() MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
else else
null null
return subRemarks?.toString() ?: "" return subRemarks?.toString() ?: ""
} }
/** fun removeServerSub(guid: String, position: Int) {
* Shares server configuration val idx = data.indexOfFirst { it.guid == guid }
* Displays a dialog with sharing options and executes the selected action if (idx >= 0) {
* @param guid The server unique identifier data.removeAt(idx)
* @param profile The server configuration notifyItemRemoved(idx)
* @param position The position in the list notifyItemRangeChanged(idx, data.size - idx)
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> mActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
if (share_method.isNotEmpty()) {
ivBinding.ivQcode.contentDescription = share_method[0]
} else {
ivBinding.ivQcode.contentDescription = "QR Code"
}
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
mActivity.toastSuccess(R.string.toast_success)
} else {
mActivity.toastError(R.string.toast_failure)
} }
} }
/** fun setSelectServer(fromPosition: Int, toPosition: Int) {
* Shares full server configuration content to clipboard notifyItemChanged(fromPosition)
* @param guid The server unique identifier notifyItemChanged(toPosition)
*/
private fun shareFullContent(guid: String) {
mActivity.lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(mActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
mActivity.toastSuccess(R.string.toast_success)
} else {
mActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", isRunning)
.putExtra("createConfigType", profile.configType.value)
if (profile.configType == EConfigType.CUSTOM) {
mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
} else if (profile.configType == EConfigType.POLICYGROUP) {
mActivity.startActivity(intent.setClass(mActivity, ServerGroupActivity::class.java))
} else {
mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
}
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid != MmkvManager.getSelectServer()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
} else {
application.toast(R.string.toast_action_not_allowed)
}
}
/**
* Executes the actual server removal process
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
mActivity.mainViewModel.removeServer(guid)
notifyItemRemoved(position)
notifyItemRangeChanged(position, data.size)
}
/**
* Sets the selected server
* Updates UI and restarts service if needed
* @param guid The server unique identifier to select
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
if (!TextUtils.isEmpty(selected)) {
notifyItemChanged(mActivity.mainViewModel.getPosition(selected.orEmpty()))
}
notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
if (isRunning) {
mActivity.restartV2Ray()
}
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
@@ -363,7 +189,10 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
BaseViewHolder(itemFooterBinding.root) BaseViewHolder(itemFooterBinding.root)
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean { override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
mActivity.mainViewModel.swapServer(fromPosition, toPosition) mainViewModel.swapServer(fromPosition, toPosition)
if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition)
}
notifyItemMoved(fromPosition, toPosition) notifyItemMoved(fromPosition, toPosition)
return true return true
} }
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
@@ -23,6 +22,7 @@ import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.AppManagerUtil import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.PerAppProxyViewModel import com.v2ray.ang.viewmodel.PerAppProxyViewModel
import es.dmoral.toasty.Toasty import es.dmoral.toasty.Toasty
@@ -95,11 +95,11 @@ class PerAppProxyActivity : BaseActivity() {
} }
appsAll = apps appsAll = apps
adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, viewModel) adapter = PerAppProxyAdapter(apps, viewModel)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
} catch (e: Exception) { } catch (e: Exception) {
Log.e(ANG_PACKAGE, "Error loading apps", e) LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
} finally { } finally {
hideLoading() hideLoading()
} }
@@ -133,6 +133,7 @@ class PerAppProxyActivity : BaseActivity() {
allowPerAppProxy() allowPerAppProxy()
true true
} }
R.id.invert_selection -> { R.id.invert_selection -> {
invertSelection() invertSelection()
allowPerAppProxy() allowPerAppProxy()
@@ -194,7 +195,7 @@ class PerAppProxyActivity : BaseActivity() {
content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: "" content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
} }
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
//Log.i(AppConfig.TAG, content) //LogUtil.i(AppConfig.TAG, content)
selectProxyApp(content, true) selectProxyApp(content, true)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
hideLoading() hideLoading()
@@ -258,7 +259,7 @@ class PerAppProxyActivity : BaseActivity() {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Error selecting proxy app", e) LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
return false return false
} }
return true return true
@@ -292,7 +293,7 @@ class PerAppProxyActivity : BaseActivity() {
} }
} }
adapter = PerAppProxyAdapter(this, apps, adapter?.viewModel ?: viewModel) adapter = PerAppProxyAdapter(apps, adapter?.viewModel ?: viewModel)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
refreshData() refreshData()
return true return true
@@ -8,8 +8,10 @@ import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.viewmodel.PerAppProxyViewModel import com.v2ray.ang.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, val viewModel: PerAppProxyViewModel) : class PerAppProxyAdapter(
RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() { val apps: List<AppInfo>,
val viewModel: PerAppProxyViewModel
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object { companion object {
private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_HEADER = 0
@@ -37,6 +39,7 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, va
) )
BaseViewHolder(view) BaseViewHolder(view)
} }
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false)) else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
} }
} }
@@ -3,13 +3,18 @@ package com.v2ray.ang.ui
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope 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.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.dto.RulesetItem import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -19,21 +24,49 @@ class RoutingEditActivity : BaseActivity() {
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) } private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
private val position by lazy { intent.getIntExtra("position", -1) } private val position by lazy { intent.getIntExtra("position", -1) }
private val outbound_tag: Array<out String> by lazy {
resources.getStringArray(R.array.outbound_tag)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
setupOutboundTagInput()
val rulesetItem = SettingsManager.getRoutingRuleset(position) val rulesetItem = SettingsManager.getRoutingRuleset(position)
if (rulesetItem != null) { if (rulesetItem != null) {
bindingServer(rulesetItem) bindingServer(rulesetItem)
} else { } else {
clearServer() clearServer()
} }
SettingsManager.canUseProcessRouting().let { canUse ->
binding.etProcess.isEnabled = canUse
}
}
/**
* 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 = MmkvManager.decodeAllServerList()
.mapNotNull { id -> MmkvManager.decodeServerConfig(id)?.remarks }
.filter { it.isNotBlank() }
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 { private fun bindingServer(rulesetItem: RulesetItem): Boolean {
@@ -41,18 +74,18 @@ class RoutingEditActivity : BaseActivity() {
binding.chkLocked.isChecked = rulesetItem.locked == true binding.chkLocked.isChecked = rulesetItem.locked == true
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(",")) binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.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.etPort.text = Utils.getEditable(rulesetItem.port)
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(",")) binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
binding.etNetwork.text = Utils.getEditable(rulesetItem.network) binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag) // Set text directly; filter won't fire because we're not using setText(filter=true)
binding.spOutboundTag.setSelection(outbound) binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
return true return true
} }
private fun clearServer(): Boolean { private fun clearServer(): Boolean {
binding.etRemarks.text = null binding.etRemarks.text = null
binding.spOutboundTag.setSelection(0) binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
return true return true
} }
@@ -62,15 +95,13 @@ class RoutingEditActivity : BaseActivity() {
rulesetItem.apply { rulesetItem.apply {
remarks = binding.etRemarks.text.toString() remarks = binding.etRemarks.text.toString()
locked = binding.chkLocked.isChecked locked = binding.chkLocked.isChecked
domain = binding.etDomain.text.toString().takeIf { it.isNotEmpty() } domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
ip = binding.etIp.text.toString().takeIf { it.isNotEmpty() } process = binding.etProcess.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
protocol = binding.etProtocol.text.toString().takeIf { it.isNotEmpty() } port = binding.etPort.text.toString().nullIfBlank()
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } network = binding.etNetwork.text.toString().nullIfBlank()
port = binding.etPort.text.toString().takeIf { it.isNotEmpty() } outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY }
network = binding.etNetwork.text.toString().takeIf { it.isNotEmpty() }
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
} }
if (rulesetItem.remarks.isNullOrEmpty()) { if (rulesetItem.remarks.isNullOrEmpty()) {
@@ -1,13 +1,10 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -15,23 +12,25 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class RoutingSettingActivity : BaseActivity() { class RoutingSettingActivity : HelperBaseActivity() {
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) } private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
private val ownerActivity: RoutingSettingActivity
get() = this
private val viewModel: RoutingSettingsViewModel by viewModels() private val viewModel: RoutingSettingsViewModel by viewModels()
private lateinit var adapter: RoutingSettingRecyclerAdapter private lateinit var adapter: RoutingSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null private var mItemTouchHelper: ItemTouchHelper? = null
@@ -42,22 +41,12 @@ class RoutingSettingActivity : BaseActivity() {
resources.getStringArray(R.array.preset_rulesets) resources.getStringArray(R.array.preset_rulesets)
} }
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRcodeForRulesets.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
adapter = RoutingSettingRecyclerAdapter(this, viewModel) adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
@@ -87,7 +76,7 @@ class RoutingSettingActivity : BaseActivity() {
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true } R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
R.id.import_predefined_rulesets -> importPredefined().let { true } R.id.import_predefined_rulesets -> importPredefined().let { true }
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true } R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
R.id.import_rulesets_from_qrcode -> requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA).let { true } R.id.import_rulesets_from_qrcode -> importQRcode()
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true } R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
@@ -103,7 +92,7 @@ class RoutingSettingActivity : BaseActivity() {
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value) MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
binding.tvDomainStrategySummary.text = value binding.tvDomainStrategySummary.text = value
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to set domain strategy", e) LogUtil.e(AppConfig.TAG, "Failed to set domain strategy", e)
} }
}.show() }.show()
} }
@@ -121,7 +110,7 @@ class RoutingSettingActivity : BaseActivity() {
} }
} }
} catch (e: Exception) { } 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) { _, _ -> .setNegativeButton(android.R.string.cancel) { _, _ ->
@@ -137,7 +126,7 @@ class RoutingSettingActivity : BaseActivity() {
val clipboard = try { val clipboard = try {
Utils.getClipboard(this) Utils.getClipboard(this)
} catch (e: Exception) { } 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) toastError(R.string.toast_failure)
return@setPositiveButton return@setPositiveButton
} }
@@ -159,6 +148,15 @@ class RoutingSettingActivity : BaseActivity() {
.show() .show()
} }
private fun importQRcode(): Boolean {
launchQRCodeScanner { scanResult ->
if (scanResult != null) {
importRulesetsFromQRcode(scanResult)
}
}
return true
}
private fun export2Clipboard() { private fun export2Clipboard() {
val rulesetList = MmkvManager.decodeRoutingRulesets() val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) { if (rulesetList.isNullOrEmpty()) {
@@ -169,11 +167,6 @@ class RoutingSettingActivity : BaseActivity() {
} }
} }
private val scanQRcodeForRulesets = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importRulesetsFromQRcode(it.data?.getStringExtra("SCAN_RESULT"))
}
}
private fun importRulesetsFromQRcode(qrcode: String?): Boolean { private fun importRulesetsFromQRcode(qrcode: String?): Boolean {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip) AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
@@ -202,4 +195,23 @@ class RoutingSettingActivity : BaseActivity() {
viewModel.reload() viewModel.reload()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
} }
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
}
override fun onRemove(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
refreshData()
}
}
} }
@@ -1,24 +1,23 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
class RoutingSettingRecyclerAdapter( class RoutingSettingRecyclerAdapter(
val activity: RoutingSettingActivity, private val viewModel: RoutingSettingsViewModel,
private val viewModel: RoutingSettingsViewModel private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(), ) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
ItemTouchHelperAdapter { ItemTouchHelperAdapter {
private var mActivity: RoutingSettingActivity = activity
override fun getItemCount() = viewModel.getAll().size override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
@@ -26,17 +25,14 @@ class RoutingSettingRecyclerAdapter(
val ruleset = rulesets[position] val ruleset = rulesets[position]
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks 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.outboundTag.text = ruleset.outboundTag
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener { holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
mActivity.startActivity( adapterListener?.onEdit("", position)
Intent(mActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
} }
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
@@ -76,7 +72,7 @@ class RoutingSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
@@ -1,27 +1,13 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
class ScScannerActivity : BaseActivity() { class ScScannerActivity : HelperBaseActivity() {
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCode.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -29,24 +15,20 @@ class ScScannerActivity : BaseActivity() {
importQRcode() importQRcode()
} }
private fun importQRcode(): Boolean { private fun importQRcode() {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA) launchQRCodeScanner { scanResult ->
return true if (scanResult != null) {
} val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (count + countSub > 0) {
if (it.resultCode == RESULT_OK) { toastSuccess(R.string.toast_success)
val scanResult = it.data?.getStringExtra("SCAN_RESULT").orEmpty() } else {
val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false) toastError(R.string.toast_failure)
}
if (count + countSub > 0) { startActivity(Intent(this, MainActivity::class.java))
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
} }
finish()
startActivity(Intent(this, MainActivity::class.java))
} }
finish()
} }
} }
@@ -0,0 +1,20 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
class ScStartActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (!V2RayServiceManager.isRunning()) {
V2RayServiceManager.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.handler.V2RayServiceManager
class ScStopActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(this)
}
finish()
}
}
@@ -1,64 +1,32 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityNoneBinding
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder import com.v2ray.ang.util.QRCodeDecoder
import io.github.g00fy2.quickie.QRResult import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanCustomCode import io.github.g00fy2.quickie.ScanCustomCode
import io.github.g00fy2.quickie.config.BarcodeFormat
import io.github.g00fy2.quickie.config.ScannerConfig import io.github.g00fy2.quickie.config.ScannerConfig
class ScannerActivity : BaseActivity() { class ScannerActivity : HelperBaseActivity() {
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult) private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
if (text.isNullOrEmpty()) {
toast(R.string.toast_decoding_failed)
} else {
finished(text)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
}
}
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
showFileChooser()
} else {
toast(R.string.toast_permission_denied)
}
}
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.menu_item_import_config_qrcode))
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) { if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) {
launchScan() launchScan()
} }
@@ -70,6 +38,7 @@ class ScannerActivity : BaseActivity() {
setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
setShowCloseButton(true) // show or hide (default) close button setShowCloseButton(true) // show or hide (default) close button
setBarcodeFormats(listOf(BarcodeFormat.QR_CODE))
} }
) )
} }
@@ -101,17 +70,7 @@ class ScannerActivity : BaseActivity() {
} }
R.id.select_photo -> { R.id.select_photo -> {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { showFileChooser()
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
showFileChooser()
} else {
requestPermissionLauncher.launch(permission)
}
true true
} }
@@ -120,15 +79,25 @@ class ScannerActivity : BaseActivity() {
} }
private fun showFileChooser() { private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT) launchFileChooser("image/*") { uri ->
intent.type = "image/*" if (uri == null) {
intent.addCategory(Intent.CATEGORY_OPENABLE) return@launchFileChooser
//intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) }
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
try { val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser))) if (text.isNullOrEmpty()) {
} catch (ex: android.content.ActivityNotFoundException) { toast(R.string.toast_decoding_failed)
toast(R.string.toast_require_file_manager) } else {
finished(text)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
} }
} }
} }
@@ -20,12 +20,14 @@ import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
@@ -128,15 +130,17 @@ class ServerActivity : BaseActivity() {
private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) } private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) }
private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) } private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) }
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) } private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
private val et_pinsha256: EditText? by lazy { findViewById(R.id.et_pinsha256) }
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) } 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_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) } 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 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 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 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 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 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) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -154,6 +158,7 @@ class ServerActivity : BaseActivity() {
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2 EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
EConfigType.POLICYGROUP -> null EConfigType.POLICYGROUP -> null
else -> null
} ?: return } ?: return
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString()) setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
@@ -238,6 +243,7 @@ class ServerActivity : BaseActivity() {
else -> null else -> null
}.orEmpty() }.orEmpty()
) )
et_fm?.text = Utils.getEditable(config?.finalMask)
layout_extra?.visibility = layout_extra?.visibility =
when (networks[position]) { when (networks[position]) {
@@ -273,7 +279,8 @@ class ServerActivity : BaseActivity() {
container_spider_x, container_spider_x,
container_mldsa65_verify, container_mldsa65_verify,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.GONE } ).forEach { it?.visibility = View.GONE }
} }
@@ -285,7 +292,8 @@ class ServerActivity : BaseActivity() {
container_alpn, container_alpn,
container_allow_insecure, container_allow_insecure,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.VISIBLE } ).forEach { it?.visibility = View.VISIBLE }
listOf( listOf(
container_public_key, container_public_key,
@@ -305,7 +313,8 @@ class ServerActivity : BaseActivity() {
container_alpn, container_alpn,
container_allow_insecure, container_allow_insecure,
container_ech_config_list, container_ech_config_list,
container_ech_force_query container_ech_force_query,
container_pinned_ca256
).forEach { it?.visibility = View.GONE } ).forEach { it?.visibility = View.GONE }
listOf( listOf(
container_public_key, container_public_key,
@@ -360,7 +369,6 @@ class ServerActivity : BaseActivity() {
et_obfs_password?.text = Utils.getEditable(config.obfsPassword) et_obfs_password?.text = Utils.getEditable(config.obfsPassword)
et_port_hop?.text = Utils.getEditable(config.portHopping) et_port_hop?.text = Utils.getEditable(config.portHopping)
et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval) et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval)
et_pinsha256?.text = Utils.getEditable(config.pinSHA256)
et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown) et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown)
et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp) et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp)
} }
@@ -393,6 +401,7 @@ class ServerActivity : BaseActivity() {
val index = Utils.arrayFind(echForceQuerys, it) val index = Utils.arrayFind(echForceQuerys, it)
index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) } 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) { } else if (config.security == REALITY) {
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty()) et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
et_short_id?.text = Utils.getEditable(config.shortId.orEmpty()) et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
@@ -483,14 +492,23 @@ 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) saveCommon(config)
saveStreamSettings(config) saveStreamSettings(config)
saveTls(config) saveTls(config)
config.description = AngConfigManager.generateDescription(config)
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) { if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty() config.subscriptionId = subscriptionId.orEmpty()
} }
//Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "") //LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
MmkvManager.encodeServerConfig(editGuid, config) MmkvManager.encodeServerConfig(editGuid, config)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
finish() finish()
@@ -525,8 +543,7 @@ class ServerActivity : BaseActivity() {
} else if (config.configType == EConfigType.HYSTERIA2) { } else if (config.configType == EConfigType.HYSTERIA2) {
config.obfsPassword = et_obfs_password?.text?.toString() config.obfsPassword = et_obfs_password?.text?.toString()
config.portHopping = et_port_hop?.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.pinSHA256 = et_pinsha256?.text?.toString()
config.bandwidthDown = et_bandwidth_down?.text?.toString() config.bandwidthDown = et_bandwidth_down?.text?.toString()
config.bandwidthUp = et_bandwidth_up?.text?.toString() config.bandwidthUp = et_bandwidth_up?.text?.toString()
} }
@@ -550,7 +567,8 @@ class ServerActivity : BaseActivity() {
profileItem.serviceName = path profileItem.serviceName = path
profileItem.authority = requestHost profileItem.authority = requestHost
profileItem.xhttpMode = transportTypes(networks[network])[type] 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()
} }
private fun saveTls(config: ProfileItem) { private fun saveTls(config: ProfileItem) {
@@ -565,6 +583,7 @@ class ServerActivity : BaseActivity() {
val mldsa65Verify = et_mldsa65_verify?.text?.toString() val mldsa65Verify = et_mldsa65_verify?.text?.toString()
val echConfigList = et_ech_config_list?.text?.toString() val echConfigList = et_ech_config_list?.text?.toString()
val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0 val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0
val pinnedCA256 = et_pinned_ca256?.text?.toString()
val allowInsecure = val allowInsecure =
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) { if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
@@ -584,6 +603,7 @@ class ServerActivity : BaseActivity() {
config.mldsa65Verify = mldsa65Verify config.mldsa65Verify = mldsa65Verify
config.echConfigList = echConfigList config.echConfigList = echConfigList
config.echForceQuery = echForceQuerys[echForceQueryIndex] config.echForceQuery = echForceQuerys[echForceQueryIndex]
config.pinnedCA256 = pinnedCA256
} }
private fun transportTypes(network: String?): Array<out String> { private fun transportTypes(network: String?): Array<out String> {
@@ -631,7 +651,7 @@ class ServerActivity : BaseActivity() {
finish() finish()
} }
} else { } else {
application.toast(R.string.toast_action_not_allowed) toast(R.string.toast_action_not_allowed)
} }
} }
return true return true
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@@ -11,12 +10,14 @@ import com.blacksquircle.ui.language.json.JsonLanguage
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
class ServerCustomConfigActivity : BaseActivity() { class ServerCustomConfigActivity : BaseActivity() {
@@ -78,7 +79,7 @@ class ServerCustomConfigActivity : BaseActivity() {
val profileItem = try { val profileItem = try {
CustomFmt.parse(binding.editor.text.toString()) CustomFmt.parse(binding.editor.text.toString())
} catch (e: Exception) { } 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}") toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
return false return false
} }
@@ -89,6 +90,7 @@ class ServerCustomConfigActivity : BaseActivity() {
} }
config.server = profileItem?.server config.server = profileItem?.server
config.serverPort = profileItem?.serverPort config.serverPort = profileItem?.serverPort
config.description = AngConfigManager.generateDescription(config)
MmkvManager.encodeServerConfig(editGuid, config) MmkvManager.encodeServerConfig(editGuid, config)
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString()) MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
@@ -8,8 +8,9 @@ import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerGroupBinding import com.v2ray.ang.databinding.ActivityServerGroupBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -66,6 +67,11 @@ class ServerGroupActivity : BaseActivity() {
private fun clearServer(): Boolean { private fun clearServer(): Boolean {
binding.etRemarks.text = null binding.etRemarks.text = null
binding.etPolicyGroupFilter.text = null binding.etPolicyGroupFilter.text = null
if (subscriptionId.isNotNullEmpty()) {
val pos = subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
binding.spPolicyGroupSubId.setSelection(pos)
}
return true return true
} }
@@ -90,6 +96,9 @@ class ServerGroupActivity : BaseActivity() {
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) { if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty() config.subscriptionId = subscriptionId.orEmpty()
} }
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
MmkvManager.encodeServerConfig(editGuid, config) MmkvManager.encodeServerConfig(editGuid, config)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
finish() finish()
@@ -119,13 +128,13 @@ class ServerGroupActivity : BaseActivity() {
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
subIds.clear() subIds.clear()
subIds.add("") // index 0 => All subIds.add("") // index 0 => All
subs.forEach { (id, item) -> subs.forEach { sub ->
val name = when { val name = when {
item.remarks.isNotBlank() -> item.remarks sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
else -> id else -> sub.guid
} }
displayList.add(name) displayList.add(name)
subIds.add(id) subIds.add(sub.guid)
} }
val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList) val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList)
subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
@@ -15,29 +15,24 @@ import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.extension.toLongEx import com.v2ray.ang.extension.toLongEx
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvPreferenceDataStore
import com.v2ray.ang.handler.SubscriptionUpdater import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class SettingsActivity : BaseActivity() { class SettingsActivity : BaseActivity() {
//private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(R.layout.activity_settings)
setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings)) setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings))
//settingsViewModel.startListenPreferenceChange()
} }
class SettingsFragment : PreferenceFragmentCompat() { class SettingsFragment : PreferenceFragmentCompat() {
// private val perAppProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PER_APP_PROXY) }
private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) } private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) } private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) } private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) } private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) } private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) } private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
@@ -55,18 +50,13 @@ class SettingsActivity : BaseActivity() {
private val autoUpdateCheck by lazy { findPreference<CheckBoxPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE) } 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 autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
// private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
// private val remoteDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_REMOTE_DNS) }
// private val domesticDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DOMESTIC_DNS) }
// private val dnsHosts by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DNS_HOSTS) }
// private val delayTestUrl by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DELAY_TEST_URL) }
// private val ipApiUrl by lazy { findPreference<EditTextPreference>(AppConfig.PREF_IP_API_URL) }
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) } private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
// private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) } private val 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 hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
// private val useTun by lazy { findPreference<ListPreference>(AppConfig.PREF_TUN) } private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
override fun onCreatePreferences(bundle: Bundle?, s: String?) { override fun onCreatePreferences(bundle: Bundle?, s: String?) {
// Use MMKV as the storage backend for all Preferences // Use MMKV as the storage backend for all Preferences
@@ -77,30 +67,10 @@ class SettingsActivity : BaseActivity() {
initPreferenceSummaries() initPreferenceSummaries()
// perAppProxy?.setOnPreferenceClickListener {
// startActivity(Intent(activity, PerAppProxyActivity::class.java))
// perAppProxy?.isChecked = true
// false
// }
localDns?.setOnPreferenceChangeListener { _, any -> localDns?.setOnPreferenceChangeListener { _, any ->
updateLocalDns(any as Boolean) updateLocalDns(any as Boolean)
true true
} }
// localDnsPort?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// localDnsPort?.summary = nval.ifEmpty { AppConfig.PORT_LOCAL_DNS }
// true
// }
// vpnDns?.setOnPreferenceChangeListener { _, any ->
// vpnDns?.summary = any as String
// true
// }
// vpnMtu?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// vpnMtu?.summary = nval.ifEmpty { AppConfig.VPN_MTU.toString() }
// true
// }
mux?.setOnPreferenceChangeListener { _, newValue -> mux?.setOnPreferenceChangeListener { _, newValue ->
updateMux(newValue as Boolean) updateMux(newValue as Boolean)
@@ -119,18 +89,6 @@ class SettingsActivity : BaseActivity() {
updateFragment(newValue as Boolean) updateFragment(newValue as Boolean)
true true
} }
// fragmentPackets?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentPackets(newValue as String)
// true
// }
// fragmentLength?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentLength(newValue as String)
// true
// }
// fragmentInterval?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentInterval(newValue as String)
// true
// }
autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue -> autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as Boolean val value = newValue as Boolean
@@ -141,77 +99,47 @@ class SettingsActivity : BaseActivity() {
} }
true true
} }
// autoUpdateInterval?.setOnPreferenceChangeListener { _, any -> mode?.setOnPreferenceChangeListener { pref, newValue ->
// var nval = any as String val valueStr = newValue.toString()
// (pref as? ListPreference)?.let { lp ->
// // It must be greater than 15 minutes because WorkManager couldn't run tasks under 15 minutes intervals val idx = lp.findIndexOfValue(valueStr)
// nval = lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
// if (TextUtils.isEmpty(nval) || nval.toLongEx() < 15) AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL else nval }
// autoUpdateInterval?.summary = nval updateMode(valueStr)
// configureUpdateTask(nval.toLongEx())
// true
// }
// socksPort?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// socksPort?.summary = nval.ifEmpty { AppConfig.PORT_SOCKS }
// true
// }
//
// remoteDns?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// remoteDns?.summary = nval.ifEmpty { AppConfig.DNS_PROXY }
// true
// }
// domesticDns?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// domesticDns?.summary = nval.ifEmpty { AppConfig.DNS_DIRECT }
// true
// }
// dnsHosts?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// dnsHosts?.summary = nval
// true
// }
// delayTestUrl?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// delayTestUrl?.summary = nval.ifEmpty { AppConfig.DELAY_TEST_URL }
// true
// }
// ipApiUrl?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// ipApiUrl?.summary = nval.ifEmpty { AppConfig.IP_API_URL }
// true
// }
mode?.setOnPreferenceChangeListener { _, newValue ->
updateMode(newValue.toString())
true true
} }
mode?.dialogLayoutResource = R.layout.preference_with_help_link mode?.dialogLayoutResource = R.layout.preference_with_help_link
//loglevel.summary = "LogLevel"
// useTun?.setOnPreferenceChangeListener { _, newValue -> useHevTun?.setOnPreferenceChangeListener { _, newValue ->
// updateHevTunSettings(newValue as String == AppConfig.TUN_hevsocks5) updateHevTunSettings(newValue as Boolean)
// true true
// } }
// hevTunRwTimeout?.setOnPreferenceChangeListener { _, any -> dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
// val nval = any as String updateDynamicSocksPort(newValue as Boolean)
// hevTunRwTimeout?.summary = nval.ifEmpty { AppConfig.HEVTUN_RW_TIMEOUT } true
// true }
// }
} }
private fun initPreferenceSummaries() { private fun initPreferenceSummaries() {
fun updateSummary(pref: androidx.preference.Preference) { fun updateSummary(pref: androidx.preference.Preference) {
when (pref) { when (pref) {
is EditTextPreference -> { 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 -> 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 true
} }
} }
is ListPreference -> { is ListPreference -> {
pref.summary = pref.entry ?: "" pref.summary = pref.entry ?: ""
pref.setOnPreferenceChangeListener { p, newValue -> pref.setOnPreferenceChangeListener { p, newValue ->
@@ -221,6 +149,7 @@ class SettingsActivity : BaseActivity() {
true true
} }
} }
is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> { is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> {
} }
} }
@@ -240,6 +169,8 @@ class SettingsActivity : BaseActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
// Initialize mode-dependent UI states // Initialize mode-dependent UI states
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN)) updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
@@ -252,100 +183,11 @@ class SettingsActivity : BaseActivity() {
// Initialize auto-update interval state // Initialize auto-update interval state
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false) autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
// localDns?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED, false) updateDynamicSocksPort(MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false))
// fakeDns?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
// appendHttpProxy?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
// vpnDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
// vpnMtu?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
// mux?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
// muxConcurrency?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8")
// muxXudpConcurrency?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
// fragment?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
// fragmentPackets?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello")
// fragmentLength?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
// fragmentInterval?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
// autoUpdateCheck?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
// autoUpdateInterval?.summary =
// MmkvManager.decodeSettingsString(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
// socksPort?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
// remoteDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
// domesticDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
// dnsHosts?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
// delayTestUrl?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
// ipApiUrl?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
// hevTunRwTimeout?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
// updateHevTunSettings(MmkvManager.decodeSettingsString(AppConfig.PREF_TUN, AppConfig.TUN_hevsocks5) == AppConfig.TUN_hevsocks5)
// initSharedPreference()
} }
private fun initSharedPreference() { private fun updateMode(value: String?) {
// listOf( val vpn = value == VPN
// //localDnsPort,
// vpnDns,
// vpnMtu,
// muxConcurrency,
// muxXudpConcurrency,
// fragmentLength,
// fragmentInterval,
// autoUpdateInterval,
// socksPort,
// remoteDns,
// domesticDns,
// delayTestUrl,
// ipApiUrl,
// hevTunRwTimeout
// ).forEach { key ->
// key?.summary = key.text.toString()
// }
// listOf(
// AppConfig.PREF_SNIFFING_ENABLED,
// AppConfig.PREF_USE_HEV_TUNNEL
// ).forEach { key ->
// findPreference<CheckBoxPreference>(key)?.isChecked =
// MmkvManager.decodeSettingsBool(key, true)
// }
//
// listOf(
// AppConfig.PREF_ROUTE_ONLY_ENABLED,
// AppConfig.PREF_IS_BOOTED,
// AppConfig.PREF_BYPASS_APPS,
// AppConfig.PREF_SPEED_ENABLED,
// AppConfig.PREF_CONFIRM_REMOVE,
// AppConfig.PREF_START_SCAN_IMMEDIATE,
// AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
// AppConfig.PREF_PREFER_IPV6,
// AppConfig.PREF_PROXY_SHARING,
// AppConfig.PREF_ALLOW_INSECURE
// ).forEach { key ->
// findPreference<CheckBoxPreference>(key)?.isChecked =
// MmkvManager.decodeSettingsBool(key, false)
// }
//
// listOf(
// AppConfig.PREF_VPN_BYPASS_LAN,
// AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX,
// AppConfig.PREF_ROUTING_DOMAIN_STRATEGY,
// AppConfig.PREF_MUX_XUDP_QUIC,
// AppConfig.PREF_FRAGMENT_PACKETS,
// AppConfig.PREF_LANGUAGE,
// AppConfig.PREF_UI_MODE_NIGHT,
// AppConfig.PREF_LOGLEVEL,
// AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD,
// AppConfig.PREF_MODE,
// AppConfig.PREF_HEV_TUNNEL_LOGLEVEL
// ).forEach { key ->
// if (MmkvManager.decodeSettingsString(key) != null) {
// findPreference<ListPreference>(key)?.value = MmkvManager.decodeSettingsString(key)
// }
// }
}
private fun updateMode(mode: String?) {
val vpn = mode == VPN
// perAppProxy?.isEnabled = vpn
// perAppProxy?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
localDns?.isEnabled = vpn localDns?.isEnabled = vpn
fakeDns?.isEnabled = vpn fakeDns?.isEnabled = vpn
appendHttpProxy?.isEnabled = vpn appendHttpProxy?.isEnabled = vpn
@@ -354,6 +196,8 @@ class SettingsActivity : BaseActivity() {
vpnBypassLan?.isEnabled = vpn vpnBypassLan?.isEnabled = vpn
vpnInterfaceAddress?.isEnabled = vpn vpnInterfaceAddress?.isEnabled = vpn
vpnMtu?.isEnabled = vpn vpnMtu?.isEnabled = vpn
useHevTun?.isEnabled = vpn
updateHevTunSettings(false)
if (vpn) { if (vpn) {
updateLocalDns( updateLocalDns(
MmkvManager.decodeSettingsBool( MmkvManager.decodeSettingsBool(
@@ -361,6 +205,12 @@ class SettingsActivity : BaseActivity() {
false false
) )
) )
updateHevTunSettings(
MmkvManager.decodeSettingsBool(
AppConfig.PREF_USE_HEV_TUNNEL,
false
)
)
} }
} }
@@ -423,29 +273,16 @@ class SettingsActivity : BaseActivity() {
fragmentPackets?.isEnabled = enabled fragmentPackets?.isEnabled = enabled
fragmentLength?.isEnabled = enabled fragmentLength?.isEnabled = enabled
fragmentInterval?.isEnabled = enabled fragmentInterval?.isEnabled = enabled
// if (enabled) {
// updateFragmentPackets(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello"))
// updateFragmentLength(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100"))
// updateFragmentInterval(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20"))
// }
} }
//
// private fun updateFragmentPackets(value: String?) { private fun updateDynamicSocksPort(enabled: Boolean) {
// fragmentPackets?.summary = value.toString() socksPort?.isEnabled = !enabled
// } }
//
// private fun updateFragmentLength(value: String?) { private fun updateHevTunSettings(enabled: Boolean) {
// fragmentLength?.summary = value.toString() hevTunLogLevel?.isEnabled = enabled
// } hevTunRwTimeout?.isEnabled = enabled
// }
// private fun updateFragmentInterval(value: String?) {
// fragmentInterval?.summary = value.toString()
// }
//
// private fun updateHevTunSettings(enabled: Boolean) {
// hevTunLogLevel?.isEnabled = enabled
// hevTunRwTimeout?.isEnabled = enabled
// }
} }
fun onModeHelpClicked(view: View) { fun onModeHelpClicked(view: View) {
@@ -14,6 +14,7 @@ import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -118,7 +119,7 @@ class SubEditActivity : BaseActivity() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm) AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
MmkvManager.removeSubscription(editSubId) SettingsManager.removeSubscriptionWithDefault(editSubId)
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
finish() finish()
} }
@@ -130,7 +131,7 @@ class SubEditActivity : BaseActivity() {
.show() .show()
} else { } else {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
MmkvManager.removeSubscription(editSubId) SettingsManager.removeSubscriptionWithDefault(editSubId)
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
finish() finish()
} }
@@ -145,10 +146,6 @@ class SubEditActivity : BaseActivity() {
del_config = menu.findItem(R.id.del_config) del_config = menu.findItem(R.id.del_config)
save_config = menu.findItem(R.id.save_config) save_config = menu.findItem(R.id.save_config)
if (editSubId.isEmpty()) {
del_config?.isVisible = false
}
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@@ -3,18 +3,26 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivitySubSettingBinding import com.v2ray.ang.databinding.ActivitySubSettingBinding
import com.v2ray.ang.extension.toastError import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel import com.v2ray.ang.viewmodel.SubscriptionsViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -22,17 +30,21 @@ import kotlinx.coroutines.launch
class SubSettingActivity : BaseActivity() { class SubSettingActivity : BaseActivity() {
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) } private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
private val ownerActivity: SubSettingActivity
get() = this
private val viewModel: SubscriptionsViewModel by viewModels() private val viewModel: SubscriptionsViewModel by viewModels()
private lateinit var adapter: SubSettingRecyclerAdapter private lateinit var adapter: SubSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null private var mItemTouchHelper: ItemTouchHelper? = null
private val share_method: Array<out String> by lazy {
resources.getStringArray(R.array.share_sub_method)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
adapter = SubSettingRecyclerAdapter(this, viewModel) adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
@@ -63,16 +75,23 @@ class SubSettingActivity : BaseActivity() {
showLoading() showLoading()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val count = AngConfigManager.updateConfigViaSubAll() val result = AngConfigManager.updateConfigViaSubAll()
delay(500L) delay(500L)
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
if (count > 0) { if (result.successCount + result.failureCount + result.skipCount == 0) {
toastSuccess(R.string.toast_success) toast(R.string.title_update_subscription_no_subscription)
refreshData() } else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
toast(getString(R.string.title_update_config_count, result.configCount))
} else { } else {
toastError(R.string.toast_failure) toast(
getString(
R.string.title_update_subscription_result,
result.configCount, result.successCount, result.failureCount, result.skipCount
)
)
} }
hideLoading() hideLoading()
refreshData()
} }
} }
@@ -88,4 +107,62 @@ class SubSettingActivity : BaseActivity() {
viewModel.reload() viewModel.reload()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
} }
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, SubEditActivity::class.java)
.putExtra("subId", guid)
)
}
override fun onRemove(guid: String, position: Int) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity)
.setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.remove(guid)
refreshData()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
} else {
viewModel.remove(guid)
refreshData()
}
}
override fun onShare(url: String) {
AlertDialog.Builder(ownerActivity)
.setItems(share_method.asList().toTypedArray()) { _, i ->
try {
when (i) {
0 -> {
val ivBinding =
ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(
QRCodeDecoder.createQRCode(
url
)
)
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
}
1 -> {
Utils.setClipboard(ownerActivity, url)
}
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
}
override fun onRefreshData() {
refreshData()
}
}
} }
@@ -1,43 +1,29 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel import com.v2ray.ang.viewmodel.SubscriptionsViewModel
class SubSettingRecyclerAdapter( class SubSettingRecyclerAdapter(
val activity: SubSettingActivity, private val viewModel: SubscriptionsViewModel,
private val viewModel: SubscriptionsViewModel private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter { ) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
private var mActivity: SubSettingActivity = activity
private val share_method: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_sub_method)
}
override fun getItemCount() = viewModel.getAll().size override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
val subscriptions = viewModel.getAll() val subscriptions = viewModel.getAll()
val subId = subscriptions[position].first val subId = subscriptions[position].guid
val subItem = subscriptions[position].second val subItem = subscriptions[position].subscription
holder.itemSubSettingBinding.tvName.text = subItem.remarks holder.itemSubSettingBinding.tvName.text = subItem.remarks
holder.itemSubSettingBinding.tvUrl.text = subItem.url holder.itemSubSettingBinding.tvUrl.text = subItem.url
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
@@ -45,14 +31,11 @@ class SubSettingRecyclerAdapter(
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemSubSettingBinding.layoutEdit.setOnClickListener { holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
mActivity.startActivity( adapterListener?.onEdit(subId, position)
Intent(mActivity, SubEditActivity::class.java)
.putExtra("subId", subId)
)
} }
holder.itemSubSettingBinding.layoutRemove.setOnClickListener { holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
removeSubscription(subId, position) adapterListener?.onRemove(subId, position)
} }
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
@@ -72,58 +55,11 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutShare.setOnClickListener { holder.itemSubSettingBinding.layoutShare.setOnClickListener {
AlertDialog.Builder(mActivity) adapterListener?.onShare(subItem.url)
.setItems(share_method.asList().toTypedArray()) { _, i ->
try {
when (i) {
0 -> {
val ivBinding =
ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
ivBinding.ivQcode.setImageBitmap(
QRCodeDecoder.createQRCode(
subItem.url
)
)
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
}
1 -> {
Utils.setClipboard(mActivity, subItem.url)
}
else -> mActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
} }
} }
} }
private fun removeSubscription(subId: String, position: Int) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeSubscriptionSub(subId, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeSubscriptionSub(subId, position)
}
}
private fun removeSubscriptionSub(subId: String, position: Int) {
viewModel.remove(subId)
notifyItemRemoved(position)
notifyItemRangeChanged(position, viewModel.getAll().size)
mActivity.refreshData()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder( return MainViewHolder(
ItemRecyclerSubSettingBinding.inflate( ItemRecyclerSubSettingBinding.inflate(
@@ -154,7 +90,7 @@ class SubSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {

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