+ tools:ignore="MissingLeanbackLauncher">
+ android:anyDensity="true"
+ android:largeScreens="true"
+ android:normalScreens="true"
+ android:smallScreens="true"
+ android:xlargeScreens="true" />
-
-
+
+
+
+
+
-
-
-
-
+
+
-
-
+
-
-
+
+
+
+ android:theme="@style/AppThemeDayNight"
+ android:usesCleartextTraffic="true"
+ tools:targetApi="m">
+
+ android:exported="true"
+ android:launchMode="singleTask"
+ android:theme="@style/AppThemeDayNight.NoActionBar">
+
-
-
-
-
-
+
+
-
-
-
-
-
-
+ android:name=".ui.SettingsActivity"
+ android:exported="false" />
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -95,53 +165,107 @@
+
-
+
+
-
+
+
+
+ android:name="android.appwidget.provider"
+ android:resource="@xml/app_widget_provider" />
+
+
+
+
+
+ android:name=".service.QSTileService"
+ android:exported="true"
+ android:foregroundServiceType="specialUse"
+ android:icon="@drawable/ic_stat_name"
+ android:label="@string/app_tile_name"
+ android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
+ android:process=":RunSoLibV2RayDaemon"
+ tools:targetApi="24">
+
+ android:exported="true"
+ android:icon="@mipmap/ic_launcher">
-
+
+
+
+
+
+
+
+
+
+
diff --git a/V2rayNG/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl b/V2rayNG/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl
deleted file mode 100644
index 2a492f78..00000000
--- a/V2rayNG/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * Copyright (C) 2012 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.vending.billing;
-
-import android.os.Bundle;
-
-/**
- * InAppBillingService is the service that provides in-app billing version 3 and beyond.
- * This service provides the following features:
- * 1. Provides a new API to get details of in-app items published for the app including
- * price, type, title and description.
- * 2. The purchase flow is synchronous and purchase information is available immediately
- * after it completes.
- * 3. Purchase information of in-app purchases is maintained within the Google Play system
- * till the purchase is consumed.
- * 4. An API to consume a purchase of an inapp item. All purchases of one-time
- * in-app items are consumable and thereafter can be purchased again.
- * 5. An API to get current purchases of the user immediately. This will not contain any
- * consumed purchases.
- *
- * All calls will give a response code with the following possible values
- * RESULT_OK = 0 - success
- * RESULT_USER_CANCELED = 1 - user pressed back or canceled a dialog
- * RESULT_BILLING_UNAVAILABLE = 3 - this billing API version is not supported for the type requested
- * RESULT_ITEM_UNAVAILABLE = 4 - requested SKU is not available for purchase
- * RESULT_DEVELOPER_ERROR = 5 - invalid arguments provided to the API
- * RESULT_ERROR = 6 - Fatal error during the API action
- * RESULT_ITEM_ALREADY_OWNED = 7 - Failure to purchase since item is already owned
- * RESULT_ITEM_NOT_OWNED = 8 - Failure to consume since item is not owned
- */
-interface IInAppBillingService {
- /**
- * Checks support for the requested billing API version, package and in-app type.
- * Minimum API version supported by this interface is 3.
- * @param apiVersion the billing version which the app is using
- * @param packageName the package name of the calling app
- * @param type type of the in-app item being purchased "inapp" for one-time purchases
- * and "subs" for subscription.
- * @return RESULT_OK(0) on success, corresponding result code on failures
- */
- int isBillingSupported(int apiVersion, String packageName, String type);
-
- /**
- * Provides details of a list of SKUs
- * Given a list of SKUs of a valid type in the skusBundle, this returns a bundle
- * with a list JSON strings containing the productId, price, title and description.
- * This API can be called with a maximum of 20 SKUs.
- * @param apiVersion billing API version that the Third-party is using
- * @param packageName the package name of the calling app
- * @param skusBundle bundle containing a StringArrayList of SKUs with key "ITEM_ID_LIST"
- * @return Bundle containing the following key-value pairs
- * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on
- * failure as listed above.
- * "DETAILS_LIST" with a StringArrayList containing purchase information
- * in JSON format similar to:
- * '{ "productId" : "exampleSku", "type" : "inapp", "price" : "$5.00",
- * "title : "Example Title", "description" : "This is an example description" }'
- */
- Bundle getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle);
-
- /**
- * Returns a pending intent to launch the purchase flow for an in-app item by providing a SKU,
- * the type, a unique purchase token and an optional developer payload.
- * @param apiVersion billing API version that the app is using
- * @param packageName package name of the calling app
- * @param sku the SKU of the in-app item as published in the developer console
- * @param type the type of the in-app item ("inapp" for one-time purchases
- * and "subs" for subscription).
- * @param developerPayload optional argument to be sent back with the purchase information
- * @return Bundle containing the following key-value pairs
- * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on
- * failure as listed above.
- * "BUY_INTENT" - PendingIntent to start the purchase flow
- *
- * The Pending intent should be launched with startIntentSenderForResult. When purchase flow
- * has completed, the onActivityResult() will give a resultCode of OK or CANCELED.
- * If the purchase is successful, the result data will contain the following key-value pairs
- * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on
- * failure as listed above.
- * "INAPP_PURCHASE_DATA" - String in JSON format similar to
- * '{"orderId":"12999763169054705758.1371079406387615",
- * "packageName":"com.example.app",
- * "productId":"exampleSku",
- * "purchaseTime":1345678900000,
- * "purchaseToken" : "122333444455555",
- * "developerPayload":"example developer payload" }'
- * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that
- * was signed with the private key of the developer
- * TODO: change this to app-specific keys.
- */
- Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type,
- String developerPayload);
-
- /**
- * Returns the current SKUs owned by the user of the type and package name specified along with
- * purchase information and a signature of the data to be validated.
- * This will return all SKUs that have been purchased in V3 and managed items purchased using
- * V1 and V2 that have not been consumed.
- * @param apiVersion billing API version that the app is using
- * @param packageName package name of the calling app
- * @param type the type of the in-app items being requested
- * ("inapp" for one-time purchases and "subs" for subscription).
- * @param continuationToken to be set as null for the first call, if the number of owned
- * skus are too many, a continuationToken is returned in the response bundle.
- * This method can be called again with the continuation token to get the next set of
- * owned skus.
- * @return Bundle containing the following key-value pairs
- * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on
- * failure as listed above.
- * "INAPP_PURCHASE_ITEM_LIST" - StringArrayList containing the list of SKUs
- * "INAPP_PURCHASE_DATA_LIST" - StringArrayList containing the purchase information
- * "INAPP_DATA_SIGNATURE_LIST"- StringArrayList containing the signatures
- * of the purchase information
- * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the
- * next set of in-app purchases. Only set if the
- * user has more owned skus than the current list.
- */
- Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken);
-
- /**
- * Consume the last purchase of the given SKU. This will result in this item being removed
- * from all subsequent responses to getPurchases() and allow re-purchase of this item.
- * @param apiVersion billing API version that the app is using
- * @param packageName package name of the calling app
- * @param purchaseToken token in the purchase information JSON that identifies the purchase
- * to be consumed
- * @return 0 if consumption succeeded. Appropriate error values for failures.
- */
- int consumePurchase(int apiVersion, String packageName, String purchaseToken);
-}
diff --git a/V2rayNG/app/src/main/assets/custom_routing_black b/V2rayNG/app/src/main/assets/custom_routing_black
new file mode 100644
index 00000000..f15daacd
--- /dev/null
+++ b/V2rayNG/app/src/main/assets/custom_routing_black
@@ -0,0 +1,142 @@
+[
+ {
+ "remarks": "绕过bittorrent",
+ "outboundTag": "direct",
+ "protocol": [
+ "bittorrent"
+ ]
+ },
+ {
+ "remarks": "Google cn",
+ "outboundTag": "proxy",
+ "domain": [
+ "domain:googleapis.cn",
+ "domain:gstatic.com"
+ ]
+ },
+ {
+ "remarks": "阻断udp443",
+ "outboundTag": "block",
+ "port": "443",
+ "network": "udp"
+ },
+ {
+ "remarks": "绕过局域网IP",
+ "outboundTag": "direct",
+ "ip": [
+ "geoip:private"
+ ]
+ },
+ {
+ "remarks": "绕过局域网域名",
+ "outboundTag": "direct",
+ "domain": [
+ "geosite:private"
+ ]
+ },
+ {
+ "remarks": "代理海外公共DNSIP",
+ "outboundTag": "proxy",
+ "ip": [
+ "1.1.1.1",
+ "1.0.0.1",
+ "2606:4700:4700::1111",
+ "2606:4700:4700::1001",
+ "1.1.1.2",
+ "1.0.0.2",
+ "2606:4700:4700::1112",
+ "2606:4700:4700::1002",
+ "1.1.1.3",
+ "1.0.0.3",
+ "2606:4700:4700::1113",
+ "2606:4700:4700::1003",
+ "8.8.8.8",
+ "8.8.4.4",
+ "2001:4860:4860::8888",
+ "2001:4860:4860::8844",
+ "94.140.14.14",
+ "94.140.15.15",
+ "2a10:50c0::ad1:ff",
+ "2a10:50c0::ad2:ff",
+ "94.140.14.15",
+ "94.140.15.16",
+ "2a10:50c0::bad1:ff",
+ "2a10:50c0::bad2:ff",
+ "94.140.14.140",
+ "94.140.14.141",
+ "2a10:50c0::1:ff",
+ "2a10:50c0::2:ff",
+ "208.67.222.222",
+ "208.67.220.220",
+ "2620:119:35::35",
+ "2620:119:53::53",
+ "208.67.222.123",
+ "208.67.220.123",
+ "2620:119:35::123",
+ "2620:119:53::123",
+ "9.9.9.9",
+ "149.112.112.112",
+ "2620:fe::9",
+ "2620:fe::fe",
+ "9.9.9.11",
+ "149.112.112.11",
+ "2620:fe::11",
+ "2620:fe::fe:11",
+ "9.9.9.10",
+ "149.112.112.10",
+ "2620:fe::10",
+ "2620:fe::fe:10",
+ "77.88.8.8",
+ "77.88.8.1",
+ "2a02:6b8::feed:0ff",
+ "2a02:6b8:0:1::feed:0ff",
+ "77.88.8.88",
+ "77.88.8.2",
+ "2a02:6b8::feed:bad",
+ "2a02:6b8:0:1::feed:bad",
+ "77.88.8.7",
+ "77.88.8.3",
+ "2a02:6b8::feed:a11",
+ "2a02:6b8:0:1::feed:a11"
+ ]
+ },
+ {
+ "remarks": "代理海外公共DNS域名",
+ "outboundTag": "proxy",
+ "domain": [
+ "domain:cloudflare-dns.com",
+ "domain:one.one.one.one",
+ "domain:dns.google",
+ "domain:adguard-dns.com",
+ "domain:opendns.com",
+ "domain:umbrella.com",
+ "domain:quad9.net",
+ "domain:yandex.net"
+ ]
+ },
+ {
+ "remarks": "代理IP",
+ "outboundTag": "proxy",
+ "ip": [
+ "geoip:facebook",
+ "geoip:fastly",
+ "geoip:google",
+ "geoip:netflix",
+ "geoip:telegram",
+ "geoip:twitter"
+ ]
+ },
+ {
+ "remarks": "代理GFW",
+ "outboundTag": "proxy",
+ "domain": [
+ "geosite:gfw",
+ "geosite:greatfire"
+ ]
+ },
+ {
+ "remarks": "最终直连",
+ "port": "0-65535",
+ "outboundTag": "direct"
+ }
+]
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/assets/custom_routing_global b/V2rayNG/app/src/main/assets/custom_routing_global
new file mode 100644
index 00000000..21bfb24d
--- /dev/null
+++ b/V2rayNG/app/src/main/assets/custom_routing_global
@@ -0,0 +1,27 @@
+[
+ {
+ "remarks": "阻断udp443",
+ "outboundTag": "block",
+ "port": "443",
+ "network": "udp"
+ },
+ {
+ "remarks": "绕过局域网IP",
+ "outboundTag": "direct",
+ "ip": [
+ "geoip:private"
+ ]
+ },
+ {
+ "remarks": "绕过局域网域名",
+ "outboundTag": "direct",
+ "domain": [
+ "geosite:private"
+ ]
+ },
+ {
+ "remarks": "最终代理",
+ "port": "0-65535",
+ "outboundTag": "proxy"
+ }
+]
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/assets/custom_routing_white b/V2rayNG/app/src/main/assets/custom_routing_white
new file mode 100644
index 00000000..e3c360c0
--- /dev/null
+++ b/V2rayNG/app/src/main/assets/custom_routing_white
@@ -0,0 +1,96 @@
+[
+ {
+ "remarks": "Google cn",
+ "outboundTag": "proxy",
+ "domain": [
+ "domain:googleapis.cn",
+ "domain:gstatic.com"
+ ]
+ },
+ {
+ "remarks": "阻断udp443",
+ "outboundTag": "block",
+ "port": "443",
+ "network": "udp"
+ },
+ {
+ "remarks": "绕过局域网IP",
+ "outboundTag": "direct",
+ "ip": [
+ "geoip:private"
+ ]
+ },
+ {
+ "remarks": "绕过局域网域名",
+ "outboundTag": "direct",
+ "domain": [
+ "geosite:private"
+ ]
+ },
+ {
+ "remarks": "绕过中国公共DNSIP",
+ "outboundTag": "direct",
+ "ip": [
+ "223.5.5.5",
+ "223.6.6.6",
+ "2400:3200::1",
+ "2400:3200:baba::1",
+ "119.29.29.29",
+ "1.12.12.12",
+ "120.53.53.53",
+ "2402:4e00::",
+ "2402:4e00:1::",
+ "180.76.76.76",
+ "2400:da00::6666",
+ "114.114.114.114",
+ "114.114.115.115",
+ "114.114.114.119",
+ "114.114.115.119",
+ "114.114.114.110",
+ "114.114.115.110",
+ "180.184.1.1",
+ "180.184.2.2",
+ "101.226.4.6",
+ "218.30.118.6",
+ "123.125.81.6",
+ "140.207.198.6",
+ "1.2.4.8",
+ "210.2.4.8",
+ "52.80.66.66",
+ "117.50.22.22",
+ "2400:7fc0:849e:200::4",
+ "2404:c2c0:85d8:901::4",
+ "117.50.10.10",
+ "52.80.52.52",
+ "2400:7fc0:849e:200::8",
+ "2404:c2c0:85d8:901::8",
+ "117.50.60.30",
+ "52.80.60.30"
+ ]
+ },
+ {
+ "remarks": "绕过中国公共DNS域名",
+ "outboundTag": "direct",
+ "domain": [
+ "domain:alidns.com",
+ "domain:doh.pub",
+ "domain:dot.pub",
+ "domain:360.cn",
+ "domain:onedns.net"
+ ]
+ },
+ {
+ "remarks": "绕过中国IP",
+ "outboundTag": "direct",
+ "ip": [
+ "geoip:cn"
+ ]
+ },
+ {
+ "remarks": "绕过中国域名",
+ "outboundTag": "direct",
+ "domain": [
+ "geosite:cn"
+ ]
+ }
+]
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/assets/custom_routing_white_iran b/V2rayNG/app/src/main/assets/custom_routing_white_iran
new file mode 100644
index 00000000..97ff1227
--- /dev/null
+++ b/V2rayNG/app/src/main/assets/custom_routing_white_iran
@@ -0,0 +1,37 @@
+[
+ {
+ "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 Iran domains",
+ "outboundTag": "direct",
+ "domain": [
+ "domain:ir",
+ "geosite:category-ir"
+ ]
+ },
+ {
+ "remarks": "Bypass Iran IP",
+ "outboundTag": "direct",
+ "ip": [
+ "geoip:ir"
+ ]
+ }
+]
diff --git a/V2rayNG/app/src/main/assets/open_source_licenses.html b/V2rayNG/app/src/main/assets/open_source_licenses.html
new file mode 100644
index 00000000..0cfd1daa
--- /dev/null
+++ b/V2rayNG/app/src/main/assets/open_source_licenses.html
@@ -0,0 +1,1285 @@
+
+
+
+
+ Open source licenses
+
+
+ Notice for packages:
+
+ - Camera Core
+
+ - Copyright © 2019 The Android Open Source Project
+
+
+
+
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+ BSD License
+https://chromium.googlesource.com/libyuv/libyuv/+/refs/heads/main/README.chromium
+
+
+
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+
+
+ - MMKV
+
+ - Copyright © 20xx Tencent Wechat, Inc.
+
+
+
+
+
+ BSD 3-Clause License
+
+Copyright (c) [year], [fullname]
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
+
+
+ Android Software Development Kit License
+https://developer.android.com/studio/terms.html
+
+
+
+
+ ML Kit Terms of Service
+https://developers.google.com/ml-kit/terms
+
+
+
+
+ Apache 2.0 License
+https://github.com/massivemadness/EditorKit/blob/master/LICENSE
+
+
+
+
+ MIT-0
+https://spdx.org/licenses/MIT-0.html
+
+
+
+
+ MIT License
+
+Copyright (c) [year] [fullname]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+
+
diff --git a/V2rayNG/app/src/main/assets/proxy_packagename.txt b/V2rayNG/app/src/main/assets/proxy_packagename.txt
index ecac8251..12abf7db 100644
--- a/V2rayNG/app/src/main/assets/proxy_packagename.txt
+++ b/V2rayNG/app/src/main/assets/proxy_packagename.txt
@@ -4,6 +4,7 @@ au.com.shiftyjelly.pocketcasts
bbc.mobile.news.ww
be.mygod.vpnhotspot
ch.protonmail.android
+cm.aptoide.pt
co.wanqu.android
com.alphainventor.filemanager
com.amazon.kindle
@@ -34,7 +35,9 @@ com.chrome.canary
com.chrome.dev
com.cl.newt66y
com.cradle.iitc_mobile
+org.exarhteam.iitc_mobile
com.cygames.shadowverse
+com.dcard.freedom
com.devhd.feedly
com.devolver.reigns2
com.discord
@@ -108,6 +111,7 @@ com.ifttt.ifttt
com.imgur.mobile
com.innologica.inoreader
com.instagram.android
+com.instagram.lite
com.instapaper.android
com.jarvanh.vpntether
com.kapp.youtube.final
@@ -115,6 +119,7 @@ com.klinker.android.twitter_l
com.lastpass.lpandroid
com.linecorp.linelite
com.lingodeer
+com.ltnnews.news
com.mediapods.tumbpods
com.mgoogle.android.gms
com.microsoft.emmx
@@ -159,6 +164,7 @@ com.slack
com.snaptube.premium
com.sololearn
com.sonelli.juicessh
+com.sparkslab.dcardreader
com.spotify.music
com.tencent.huatuo
com.termux
@@ -173,10 +179,13 @@ com.twitter.android
com.u91porn
com.u9porn
com.ubisoft.dance.justdance2015companion
+com.udn.news
com.utopia.pxview
-com.valvesoftware.android.steam.communimunity
com.valvesoftware.android.steam.community
+com.vanced.manager
com.vanced.android.youtube
+com.vanced.android.apps.youtube.music
+com.mgoogle.android.gms
com.vimeo.android.videoapp
com.vivaldi.browser
com.vivaldi.browser.snapshot
@@ -186,10 +195,12 @@ com.wire
com.wuxiangai.refactor
com.xda.labs
com.xvideos.app
+com.yahoo.mobile.client.android.superapp
com.yandex.browser
com.yandex.browser.beta
com.yandex.browser.alpha
com.z28j.feel
+com.zhiliaoapp.musically
con.medium.reader
de.apkgrabber
de.robv.android.xposed.installer
@@ -210,6 +221,7 @@ jp.bokete.app.android
jp.naver.line.android
jp.pxv.android
luo.speedometergpspro
+m.cna.com.tw.App
mark.via.gp
me.tshine.easymark
net.teeha.android.url_shortener
@@ -226,6 +238,7 @@ org.mozilla.firefox_beta
org.mozilla.focus
org.schabi.newpipe
org.telegram.messenger
+org.telegram.messenger.web
org.telegram.multi
org.telegram.plus
org.thunderdog.challegram
@@ -239,3 +252,162 @@ tw.com.gamer.android.activecenter
videodownloader.downloadvideo.downloader
uk.co.bbc.learningenglish
com.ted.android
+de.danoeh.antennapod
+com.kiwibrowser.browser
+nekox.messenger
+com.nextcloud.client
+com.aurora.store
+com.aurora.adroid
+chat.simplex.app
+im.vector.app
+network.loki.messenger
+eu.siacs.conversations
+xyz.nextalone.nagram
+net.programmierecke.radiodroid2
+im.fdx.v2ex
+ml.docilealligator.infinityforreddit
+com.bytemyth.ama
+app.vanadium.browser
+com.cakewallet.cake_wallet
+org.purplei2p.i2pd
+dk.tacit.android.foldersync.lite
+com.nononsenseapps.feeder
+com.m2049r.xmrwallet
+com.paypal.android.p2pmobile
+com.google.android.apps.googlevoice
+com.readdle.spark
+org.torproject.torbrowser
+com.deepl.mobiletranslator
+com.microsoft.bing
+com.keylesspalace.tusky
+com.ottplay.ottplay
+ru.iptvremote.android.iptv.pro
+jp.naver.line.android
+com.xmflsct.app.tooot
+com.forem.android
+app.revanced.android.youtube
+com.mgoogle.android.gms
+com.pionex.client
+vip.mytokenpocket
+im.token.app
+com.linekong.mars24
+com.feixiaohao
+com.aicoin.appandroid
+com.binance.dev
+com.kraken.trade
+com.okinc.okex.gp
+com.authy.authy
+air.com.rosettastone.mobile.CoursePlayer
+com.blizzard.bma
+com.amazon.kindle
+com.google.android.apps.fitness
+net.tsapps.appsales
+com.wemesh.android
+com.google.android.apps.googleassistant
+allen.town.focus.reader
+me.hyliu.fluent_reader_lite
+com.aljazeera.mobile
+com.ft.news
+de.marmaro.krt.ffupdater
+myradio.radio.fmradio.liveradio.radiostation
+com.google.earth
+eu.kanade.tachiyomi.j2k
+com.audials
+com.microsoft.skydrive
+com.mb.android.tg
+com.melodis.midomiMusicIdentifier.freemium
+com.foxnews.android
+ch.threema.app
+com.briarproject.briar.android
+foundation.e.apps
+com.valvesoftware.android.steam.friendsui
+com.imback.yeetalk
+so.onekey.app.wallet
+com.xc3fff0e.xmanager
+meditofoundation.medito
+com.picol.client
+com.streetwriters.notesnook
+shanghai.panewsApp.com
+org.coursera.android
+com.positron_it.zlib
+com.blizzard.messenger
+com.javdb.javrocket
+com.picacomic.fregata
+com.fxl.chacha
+me.proton.android.drive
+com.lastpass.lpandroid
+com.tradingview.tradingviewapp
+com.deviantart.android.damobile
+com.fusionmedia.investing
+com.ewa.ewaapp
+com.duolingo
+com.hellotalk
+io.github.huskydg.magisk
+com.jsy.xpgbox
+com.hostloc.app.hostloc
+com.dena.pokota
+com.vitorpamplona.amethyst
+com.zhiliaoapp.musically
+us.spotco.fennec_dos
+com.fongmi.android.tv
+com.pocketprep.android.itcybersecurity
+com.cloudtv
+com.glassdoor.app
+com.indeed.android.jobsearch
+com.linkedin.android
+com.github.tvbox.osc.bh
+com.example.douban
+com.sipnetic.app
+com.microsoft.rdc.androidx
+org.zwanoo.android.speedtest
+com.sonelli.juicessh
+com.scmp.newspulse
+org.lsposed.manager
+mnn.Android
+com.thomsonretuers.reuters
+com.guardian
+com.ttxapps.onesyncv2
+org.fcitx.fcitx5.android.updater
+com.tailscale.ipn
+tw.nekomimi.nekogram
+com.nexon.kartdrift
+io.syncapps.lemmy_sync
+com.seazon.feedme
+com.readwise
+de.spiritcroc.riotx
+com.openai.chatgpt
+io.changenow.changenow
+com.poe.android
+com.twingate
+com.blinkslabs.blinkist.android
+com.ichi2.anki
+md.obsidian
+com.musixmatch.android.lyrify
+com.cyber.turbo
+com.offsec.nethunter
+me.ghui.v2er
+com.samruston.twitter
+org.adaway
+org.swiftapps.swiftbackup
+com.zerotier.one
+com.quietmobile
+com.instagram.barcelona
+im.molly.app
+com.rvx.android.youtube
+com.deepl.mobiletranslator
+com.qingsong.yingmi
+com.lemurbrowser.exts
+com.silverdev.dnartdroid
+me.ash.reader
+de.tutao.tutanota
+dev.imranr.obtainium
+com.getsomeheadspace.android
+org.cromite.cromite
+com.nutomic.syncthingandroid
+com.bumble.app
+com.cnn.mobile.android.phone
+com.google.android.apps.authenticator2
+com.microsoft.copilot
+com.netflix.NGP.Storyteller
+com.Slack
+com.server.auditor.ssh.client
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/assets/v2ray_config.json b/V2rayNG/app/src/main/assets/v2ray_config.json
index 2783ec74..4f8c3d7e 100644
--- a/V2rayNG/app/src/main/assets/v2ray_config.json
+++ b/V2rayNG/app/src/main/assets/v2ray_config.json
@@ -54,7 +54,7 @@
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
- "alterId": 64,
+ "alterId": 0,
"security": "auto",
"level": 8
}
@@ -81,7 +81,9 @@
},
{
"protocol": "freedom",
- "settings": {},
+ "settings": {
+ "domainStrategy": "UseIP"
+ },
"tag": "direct"
},
{
@@ -95,7 +97,7 @@
}
],
"routing": {
- "domainStrategy": "IPIfNonMatch",
+ "domainStrategy": "AsIs",
"rules": []
},
"dns": {
diff --git a/V2rayNG/app/src/main/ic_launcher-web.png b/V2rayNG/app/src/main/ic_launcher-web.png
index f6fc3b8c..03a4ce8a 100644
Binary files a/V2rayNG/app/src/main/ic_launcher-web.png and b/V2rayNG/app/src/main/ic_launcher-web.png differ
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt
new file mode 100644
index 00000000..44f680b3
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt
@@ -0,0 +1,47 @@
+package com.v2ray.ang
+
+import android.content.Context
+import androidx.multidex.MultiDexApplication
+import androidx.work.Configuration
+import androidx.work.WorkManager
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.handler.SettingsManager
+
+class AngApplication : MultiDexApplication() {
+ companion object {
+ lateinit var application: AngApplication
+ }
+
+ /**
+ * Attaches the base context to the application.
+ * @param base The base context.
+ */
+ override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ application = this
+ }
+
+ private val workManagerConfiguration: Configuration = Configuration.Builder()
+ .setDefaultProcessName("${ANG_PACKAGE}:bg")
+ .build()
+
+ /**
+ * Initializes the application.
+ */
+ override fun onCreate() {
+ super.onCreate()
+
+ MMKV.initialize(this)
+
+ SettingsManager.setNightMode()
+ // Initialize WorkManager with the custom configuration
+ WorkManager.initialize(this, workManagerConfiguration)
+
+ SettingsManager.initRoutingRulesets(this)
+
+ es.dmoral.toasty.Toasty.Config.getInstance()
+ .setGravity(android.view.Gravity.BOTTOM, 0, 200)
+ .apply()
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt
new file mode 100644
index 00000000..09e3a9d5
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt
@@ -0,0 +1,248 @@
+package com.v2ray.ang
+
+
+object AppConfig {
+
+ /** The application's package name. */
+ const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
+ const val TAG = BuildConfig.APPLICATION_ID
+
+ /** Directory names used in the app's file system. */
+ const val DIR_ASSETS = "assets"
+ const val DIR_BACKUPS = "backups"
+
+ /** Legacy configuration keys. */
+ const val ANG_CONFIG = "ang_config"
+
+ /** Preferences mapped to MMKV storage. */
+ const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
+ const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
+ const val PREF_PER_APP_PROXY = "pref_per_app_proxy"
+ const val PREF_PER_APP_PROXY_SET = "pref_per_app_proxy_set"
+ const val PREF_BYPASS_APPS = "pref_bypass_apps"
+ const val PREF_LOCAL_DNS_ENABLED = "pref_local_dns_enabled"
+ const val PREF_FAKE_DNS_ENABLED = "pref_fake_dns_enabled"
+ const val PREF_APPEND_HTTP_PROXY = "pref_append_http_proxy"
+ const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
+ const val PREF_VPN_DNS = "pref_vpn_dns"
+ const val PREF_VPN_BYPASS_LAN = "pref_vpn_bypass_lan"
+ const val PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX = "pref_vpn_interface_address_config_index"
+ const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
+ const val PREF_ROUTING_RULESET = "pref_routing_ruleset"
+ const val PREF_MUX_ENABLED = "pref_mux_enabled"
+ const val PREF_MUX_CONCURRENCY = "pref_mux_concurrency"
+ const val PREF_MUX_XUDP_CONCURRENCY = "pref_mux_xudp_concurrency"
+ const val PREF_MUX_XUDP_QUIC = "pref_mux_xudp_quic"
+ const val PREF_FRAGMENT_ENABLED = "pref_fragment_enabled"
+ const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
+ const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
+ const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
+ const val SUBSCRIPTION_AUTO_UPDATE = "pref_auto_update_subscription"
+ const val SUBSCRIPTION_AUTO_UPDATE_INTERVAL = "pref_auto_update_interval"
+ const val SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL = "1440" // Default is 24 hours
+ const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
+ const val PREF_SPEED_ENABLED = "pref_speed_enabled"
+ const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
+ const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
+ const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
+ const val PREF_LANGUAGE = "pref_language"
+ const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
+ const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
+ const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
+ const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
+ const val PREF_SOCKS_PORT = "pref_socks_port"
+ const val PREF_REMOTE_DNS = "pref_remote_dns"
+ const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
+ const val PREF_DNS_HOSTS = "pref_dns_hosts"
+ const val PREF_DELAY_TEST_URL = "pref_delay_test_url"
+ const val PREF_LOGLEVEL = "pref_core_loglevel"
+ const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method"
+ const val PREF_MODE = "pref_mode"
+ const val PREF_IS_BOOTED = "pref_is_booted"
+ const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
+ const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
+
+ /** Cache keys. */
+ const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
+ const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
+
+ /** Protocol identifiers. */
+ const val PROTOCOL_FREEDOM = "freedom"
+
+ /** Broadcast actions. */
+ const val BROADCAST_ACTION_SERVICE = "com.v2ray.ang.action.service"
+ const val BROADCAST_ACTION_ACTIVITY = "com.v2ray.ang.action.activity"
+ const val BROADCAST_ACTION_WIDGET_CLICK = "com.v2ray.ang.action.widget.click"
+
+ /** Tasker extras. */
+ const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
+ const val TASKER_EXTRA_STRING_BLURB = "com.twofortyfouram.locale.intent.extra.BLURB"
+ const val TASKER_EXTRA_BUNDLE_SWITCH = "tasker_extra_bundle_switch"
+ const val TASKER_EXTRA_BUNDLE_GUID = "tasker_extra_bundle_guid"
+ const val TASKER_DEFAULT_GUID = "Default"
+
+ /** Tags for different proxy modes. */
+ const val TAG_PROXY = "proxy"
+ const val TAG_DIRECT = "direct"
+ const val TAG_BLOCKED = "block"
+ const val TAG_FRAGMENT = "fragment"
+
+ /** Network-related constants. */
+ const val UPLINK = "uplink"
+ const val DOWNLINK = "downlink"
+
+ /** URLs for various resources. */
+ const val GITHUB_URL = "https://github.com"
+ const val GITHUB_RAW_URL = "https://raw.githubusercontent.com"
+ const val GITHUB_DOWNLOAD_URL = "$GITHUB_URL/%s/releases/latest/download"
+ const val ANDROID_PACKAGE_NAME_LIST_URL = "$GITHUB_RAW_URL/2dust/androidpackagenamelist/master/proxy.txt"
+ const val APP_URL = "$GITHUB_URL/2dust/v2rayNG"
+ const val APP_API_URL = "https://api.github.com/repos/2dust/v2rayNG/releases"
+ const val APP_ISSUES_URL = "$APP_URL/issues"
+ const val APP_WIKI_MODE = "$APP_URL/wiki/Mode"
+ const val APP_PRIVACY_POLICY = "$GITHUB_RAW_URL/2dust/v2rayNG/master/CR.md"
+ const val APP_PROMOTION_URL = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
+ const val TG_CHANNEL_URL = "https://t.me/github_2dust"
+ const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
+ const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
+ const val IP_API_URL = "https://speed.cloudflare.com/meta"
+
+ /** DNS server addresses. */
+ const val DNS_PROXY = "1.1.1.1"
+ const val DNS_DIRECT = "223.5.5.5"
+ const val DNS_VPN = "1.1.1.1"
+ const val GEOSITE_PRIVATE = "geosite:private"
+ const val GEOSITE_CN = "geosite:cn"
+ const val GEOIP_PRIVATE = "geoip:private"
+ const val GEOIP_CN = "geoip:cn"
+
+ /** Ports and addresses for various services. */
+ const val PORT_LOCAL_DNS = "10853"
+ const val PORT_SOCKS = "10808"
+ const val WIREGUARD_LOCAL_ADDRESS_V4 = "172.16.0.2/32"
+ const val WIREGUARD_LOCAL_ADDRESS_V6 = "2606:4700:110:8f81:d551:a0:532e:a2b3/128"
+ const val WIREGUARD_LOCAL_MTU = "1420"
+ const val LOOPBACK = "127.0.0.1"
+
+ /** Message constants for communication. */
+ const val MSG_REGISTER_CLIENT = 1
+ const val MSG_STATE_RUNNING = 11
+ const val MSG_STATE_NOT_RUNNING = 12
+ const val MSG_UNREGISTER_CLIENT = 2
+ const val MSG_STATE_START = 3
+ const val MSG_STATE_START_SUCCESS = 31
+ const val MSG_STATE_START_FAILURE = 32
+ const val MSG_STATE_STOP = 4
+ const val MSG_STATE_STOP_SUCCESS = 41
+ const val MSG_STATE_RESTART = 5
+ const val MSG_MEASURE_DELAY = 6
+ const val MSG_MEASURE_DELAY_SUCCESS = 61
+ const val MSG_MEASURE_CONFIG = 7
+ const val MSG_MEASURE_CONFIG_SUCCESS = 71
+ const val MSG_MEASURE_CONFIG_CANCEL = 72
+
+ /** Notification channel IDs and names. */
+ const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
+ const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
+ const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
+ const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
+
+ /** Protocols Scheme **/
+ const val VMESS = "vmess://"
+ const val CUSTOM = ""
+ const val SHADOWSOCKS = "ss://"
+ const val SOCKS = "socks://"
+ const val HTTP = "http://"
+ const val VLESS = "vless://"
+ const val TROJAN = "trojan://"
+ const val WIREGUARD = "wireguard://"
+ const val TUIC = "tuic://"
+ const val HYSTERIA2 = "hysteria2://"
+ const val HY2 = "hy2://"
+
+ /** Give a good name to this, IDK*/
+ const val VPN = "VPN"
+
+ // Google API rule constants
+ const val GOOGLEAPIS_CN_DOMAIN = "domain:googleapis.cn"
+ const val GOOGLEAPIS_COM_DOMAIN = "googleapis.com"
+
+ // Android Private DNS constants
+ const val DNS_DNSPOD_DOMAIN = "dot.pub"
+ const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
+ const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
+ const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
+ const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
+ const val DNS_GOOGLE_DOMAIN = "dns.google"
+ const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
+ const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
+
+ const val DEFAULT_PORT = 443
+ const val DEFAULT_SECURITY = "auto"
+ const val DEFAULT_LEVEL = 8
+ const val DEFAULT_NETWORK = "tcp"
+ const val TLS = "tls"
+ const val REALITY = "reality"
+ const val HEADER_TYPE_HTTP = "http"
+
+ val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
+ val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
+ val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
+ val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
+ val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
+ val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
+ val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
+ val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
+
+ //minimum list https://serverfault.com/a/304791
+ val ROUTED_IP_LIST = arrayListOf(
+ "0.0.0.0/5",
+ "8.0.0.0/7",
+ "11.0.0.0/8",
+ "12.0.0.0/6",
+ "16.0.0.0/4",
+ "32.0.0.0/3",
+ "64.0.0.0/2",
+ "128.0.0.0/3",
+ "160.0.0.0/5",
+ "168.0.0.0/6",
+ "172.0.0.0/12",
+ "172.32.0.0/11",
+ "172.64.0.0/10",
+ "172.128.0.0/9",
+ "173.0.0.0/8",
+ "174.0.0.0/7",
+ "176.0.0.0/4",
+ "192.0.0.0/9",
+ "192.128.0.0/11",
+ "192.160.0.0/13",
+ "192.169.0.0/16",
+ "192.170.0.0/15",
+ "192.172.0.0/14",
+ "192.176.0.0/12",
+ "192.192.0.0/10",
+ "193.0.0.0/8",
+ "194.0.0.0/7",
+ "196.0.0.0/6",
+ "200.0.0.0/5",
+ "208.0.0.0/4",
+ "240.0.0.0/4"
+ )
+
+ val PRIVATE_IP_LIST = arrayListOf(
+ "0.0.0.0/8",
+ "10.0.0.0/8",
+ "127.0.0.0/8",
+ "172.16.0.0/12",
+ "192.168.0.0/16",
+ "169.254.0.0/16",
+ "224.0.0.0/4"
+ )
+
+ val GEO_FILES_SOURCES = arrayListOf(
+ "Loyalsoldier/v2ray-rules-dat",
+ "runetfreedom/russia-v2ray-rules-dat",
+ "Chocolate4U/Iran-v2ray-rules"
+ )
+
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AppInfo.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AppInfo.kt
new file mode 100644
index 00000000..219e35e9
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AppInfo.kt
@@ -0,0 +1,11 @@
+package com.v2ray.ang.dto
+
+import android.graphics.drawable.Drawable
+
+data class AppInfo(
+ val appName: String,
+ val packageName: String,
+ val appIcon: Drawable,
+ val isSystemApp: Boolean,
+ var isSelected: Int
+)
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AssetUrlItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AssetUrlItem.kt
new file mode 100644
index 00000000..5a8d1e60
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/AssetUrlItem.kt
@@ -0,0 +1,9 @@
+package com.v2ray.ang.dto
+
+data class AssetUrlItem(
+ var remarks: String = "",
+ var url: String = "",
+ val addedTime: Long = System.currentTimeMillis(),
+ var lastUpdated: Long = -1,
+ var locked: Boolean? = false,
+)
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/CheckUpdateResult.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/CheckUpdateResult.kt
new file mode 100644
index 00000000..be4f62e5
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/CheckUpdateResult.kt
@@ -0,0 +1,10 @@
+package com.v2ray.ang.dto
+
+data class CheckUpdateResult(
+ val hasUpdate: Boolean,
+ val latestVersion: String? = null,
+ val releaseNotes: String? = null,
+ val downloadUrl: String? = null,
+ val error: String? = null,
+ val isPreRelease: Boolean = false
+)
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ConfigResult.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ConfigResult.kt
new file mode 100644
index 00000000..c8870248
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ConfigResult.kt
@@ -0,0 +1,9 @@
+package com.v2ray.ang.dto
+
+data class ConfigResult(
+ var status: Boolean,
+ var guid: String? = null,
+ var content: String = "",
+ var socksPort: Int? = null,
+)
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/EConfigType.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/EConfigType.kt
new file mode 100644
index 00000000..ed7cad3b
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/EConfigType.kt
@@ -0,0 +1,22 @@
+package com.v2ray.ang.dto
+
+import com.v2ray.ang.AppConfig
+
+
+enum class EConfigType(val value: Int, val protocolScheme: String) {
+ VMESS(1, AppConfig.VMESS),
+ CUSTOM(2, AppConfig.CUSTOM),
+ SHADOWSOCKS(3, AppConfig.SHADOWSOCKS),
+ SOCKS(4, AppConfig.SOCKS),
+ VLESS(5, AppConfig.VLESS),
+ TROJAN(6, AppConfig.TROJAN),
+ WIREGUARD(7, AppConfig.WIREGUARD),
+
+ // TUIC(8, AppConfig.TUIC),
+ HYSTERIA2(9, AppConfig.HYSTERIA2),
+ HTTP(10, AppConfig.HTTP);
+
+ companion object {
+ fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/GitHubRelease.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/GitHubRelease.kt
new file mode 100644
index 00000000..0a7dce56
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/GitHubRelease.kt
@@ -0,0 +1,23 @@
+package com.v2ray.ang.dto
+
+import com.google.gson.annotations.SerializedName
+
+data class GitHubRelease(
+ @SerializedName("tag_name")
+ val tagName: String,
+ @SerializedName("body")
+ val body: String,
+ @SerializedName("assets")
+ val assets: List,
+ @SerializedName("prerelease")
+ val prerelease: Boolean = false,
+ @SerializedName("published_at")
+ val publishedAt: String = ""
+) {
+ data class Asset(
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("browser_download_url")
+ val browserDownloadUrl: String
+ )
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Hysteria2Bean.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Hysteria2Bean.kt
new file mode 100644
index 00000000..c4fc6582
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Hysteria2Bean.kt
@@ -0,0 +1,46 @@
+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?,
+ )
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/IPAPIInfo.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/IPAPIInfo.kt
new file mode 100644
index 00000000..97814fbb
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/IPAPIInfo.kt
@@ -0,0 +1,12 @@
+package com.v2ray.ang.dto
+
+data class IPAPIInfo(
+ var ip: String? = null,
+ var clientIp: String? = null,
+ var ip_addr: String? = null,
+ var query: String? = null,
+ var country: String? = null,
+ var country_name: String? = null,
+ var country_code: String? = null,
+ var countryCode: String? = null
+)
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Language.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Language.kt
new file mode 100644
index 00000000..46333d06
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/Language.kt
@@ -0,0 +1,20 @@
+package com.v2ray.ang.dto
+
+enum class Language(val code: String) {
+ AUTO("auto"),
+ ENGLISH("en"),
+ CHINA("zh-rCN"),
+ TRADITIONAL_CHINESE("zh-rTW"),
+ VIETNAMESE("vi"),
+ RUSSIAN("ru"),
+ PERSIAN("fa"),
+ ARABIC("ar"),
+ BANGLA("bn"),
+ BAKHTIARI("bqi-rIR");
+
+ companion object {
+ fun fromCode(code: String): Language {
+ return entries.find { it.code == code } ?: AUTO
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/NetworkType.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/NetworkType.kt
new file mode 100644
index 00000000..0e8091b9
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/NetworkType.kt
@@ -0,0 +1,18 @@
+package com.v2ray.ang.dto
+
+enum class NetworkType(val type: String) {
+ TCP("tcp"),
+ KCP("kcp"),
+ WS("ws"),
+ HTTP_UPGRADE("httpupgrade"),
+ XHTTP("xhttp"),
+ HTTP("http"),
+ H2("h2"),
+
+ //QUIC("quic"),
+ GRPC("grpc");
+
+ companion object {
+ fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ProfileItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ProfileItem.kt
new file mode 100644
index 00000000..7a1f7346
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ProfileItem.kt
@@ -0,0 +1,120 @@
+package com.v2ray.ang.dto
+
+import com.v2ray.ang.AppConfig.LOOPBACK
+import com.v2ray.ang.AppConfig.PORT_SOCKS
+import com.v2ray.ang.AppConfig.TAG_BLOCKED
+import com.v2ray.ang.AppConfig.TAG_DIRECT
+import com.v2ray.ang.AppConfig.TAG_PROXY
+import com.v2ray.ang.util.Utils
+
+data class ProfileItem(
+ val configVersion: Int = 4,
+ val configType: EConfigType,
+ var subscriptionId: String = "",
+ var addedTime: Long = System.currentTimeMillis(),
+
+ var remarks: String = "",
+ var server: String? = null,
+ var serverPort: String? = null,
+
+ var password: String? = null,
+ var method: String? = null,
+ var flow: String? = null,
+ var username: String? = null,
+
+ var network: String? = null,
+ var headerType: String? = null,
+ var host: String? = null,
+ var path: String? = null,
+ var seed: String? = null,
+ var quicSecurity: String? = null,
+ var quicKey: String? = null,
+ var mode: String? = null,
+ var serviceName: String? = null,
+ var authority: String? = null,
+ var xhttpMode: String? = null,
+ var xhttpExtra: String? = null,
+
+ var security: String? = null,
+ var sni: String? = null,
+ var alpn: String? = null,
+ var fingerPrint: String? = null,
+ var insecure: Boolean? = null,
+
+ var publicKey: String? = null,
+ var shortId: String? = null,
+ var spiderX: String? = null,
+
+ var secretKey: String? = null,
+ var preSharedKey: String? = null,
+ var localAddress: String? = null,
+ var reserved: String? = null,
+ var mtu: Int? = null,
+
+ var obfsPassword: String? = null,
+ var portHopping: String? = null,
+ var portHoppingInterval: String? = null,
+ var pinSHA256: String? = null,
+ var bandwidthDown: String? = null,
+ var bandwidthUp: String? = null,
+
+ ) {
+ companion object {
+ fun create(configType: EConfigType): ProfileItem {
+ return ProfileItem(configType = configType)
+ }
+ }
+
+ fun getAllOutboundTags(): MutableList {
+ return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
+ }
+
+ fun getServerAddressAndPort(): String {
+ if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
+ return "$LOOPBACK:$PORT_SOCKS"
+ }
+ return Utils.getIpv6Address(server) + ":" + serverPort
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other == null) return false
+ val obj = other as ProfileItem
+
+ return (this.server == obj.server
+ && this.serverPort == obj.serverPort
+ && this.password == obj.password
+ && this.method == obj.method
+ && this.flow == obj.flow
+ && this.username == obj.username
+
+ && this.network == obj.network
+ && this.headerType == obj.headerType
+ && this.host == obj.host
+ && this.path == obj.path
+ && this.seed == obj.seed
+ && this.quicSecurity == obj.quicSecurity
+ && this.quicKey == obj.quicKey
+ && this.mode == obj.mode
+ && this.serviceName == obj.serviceName
+ && this.authority == obj.authority
+ && this.xhttpMode == obj.xhttpMode
+
+ && this.security == obj.security
+ && this.sni == obj.sni
+ && this.alpn == obj.alpn
+ && this.fingerPrint == obj.fingerPrint
+ && this.publicKey == obj.publicKey
+ && this.shortId == obj.shortId
+
+ && this.secretKey == obj.secretKey
+ && this.localAddress == obj.localAddress
+ && this.reserved == obj.reserved
+ && this.mtu == obj.mtu
+
+ && this.obfsPassword == obj.obfsPassword
+ && this.portHopping == obj.portHopping
+ && this.portHoppingInterval == obj.portHoppingInterval
+ && this.pinSHA256 == obj.pinSHA256
+ )
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RoutingType.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RoutingType.kt
new file mode 100644
index 00000000..a98ac167
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RoutingType.kt
@@ -0,0 +1,20 @@
+package com.v2ray.ang.dto
+
+enum class RoutingType(val fileName: String) {
+ WHITE("custom_routing_white"),
+ BLACK("custom_routing_black"),
+ GLOBAL("custom_routing_global"),
+ WHITE_IRAN("custom_routing_white_iran");
+
+ companion object {
+ fun fromIndex(index: Int): RoutingType {
+ return when (index) {
+ 0 -> WHITE
+ 1 -> BLACK
+ 2 -> GLOBAL
+ 3 -> WHITE_IRAN
+ else -> WHITE
+ }
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RulesetItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RulesetItem.kt
new file mode 100644
index 00000000..9005f559
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/RulesetItem.kt
@@ -0,0 +1,13 @@
+package com.v2ray.ang.dto
+
+data class RulesetItem(
+ var remarks: String? = "",
+ var ip: List? = null,
+ var domain: List? = null,
+ var outboundTag: String = "",
+ var port: String? = null,
+ var network: String? = null,
+ var protocol: List? = null,
+ var enabled: Boolean = true,
+ var locked: Boolean? = false,
+)
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerAffiliationInfo.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerAffiliationInfo.kt
similarity index 100%
rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerAffiliationInfo.kt
rename to V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerAffiliationInfo.kt
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerConfig.kt
new file mode 100644
index 00000000..baa1fe48
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServerConfig.kt
@@ -0,0 +1,86 @@
+package com.v2ray.ang.dto
+
+import com.v2ray.ang.AppConfig.TAG_BLOCKED
+import com.v2ray.ang.AppConfig.TAG_DIRECT
+import com.v2ray.ang.AppConfig.TAG_PROXY
+
+data class ServerConfig(
+ val configVersion: Int = 3,
+ val configType: EConfigType,
+ var subscriptionId: String = "",
+ val addedTime: Long = System.currentTimeMillis(),
+ var remarks: String = "",
+ val outboundBean: V2rayConfig.OutboundBean? = null,
+ var fullConfig: V2rayConfig? = null
+) {
+ companion object {
+ fun create(configType: EConfigType): ServerConfig {
+ when (configType) {
+ EConfigType.VMESS,
+ EConfigType.VLESS ->
+ return ServerConfig(
+ configType = configType,
+ outboundBean = V2rayConfig.OutboundBean(
+ protocol = configType.name.lowercase(),
+ settings = V2rayConfig.OutboundBean.OutSettingsBean(
+ vnext = listOf(
+ V2rayConfig.OutboundBean.OutSettingsBean.VnextBean(
+ users = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean())
+ )
+ )
+ ),
+ streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
+ )
+ )
+
+ EConfigType.CUSTOM ->
+ return ServerConfig(configType = configType)
+
+ EConfigType.SHADOWSOCKS,
+ EConfigType.SOCKS,
+ EConfigType.HTTP,
+ EConfigType.TROJAN,
+ EConfigType.HYSTERIA2 ->
+ return ServerConfig(
+ configType = configType,
+ outboundBean = V2rayConfig.OutboundBean(
+ protocol = configType.name.lowercase(),
+ settings = V2rayConfig.OutboundBean.OutSettingsBean(
+ servers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.ServersBean())
+ ),
+ streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
+ )
+ )
+
+ EConfigType.WIREGUARD ->
+ return ServerConfig(
+ configType = configType,
+ outboundBean = V2rayConfig.OutboundBean(
+ protocol = configType.name.lowercase(),
+ settings = V2rayConfig.OutboundBean.OutSettingsBean(
+ secretKey = "",
+ peers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.WireGuardBean())
+ )
+ )
+ )
+ }
+ }
+ }
+
+ fun getProxyOutbound(): V2rayConfig.OutboundBean? {
+ if (configType != EConfigType.CUSTOM) {
+ return outboundBean
+ }
+ return fullConfig?.getProxyOutbound()
+ }
+
+ fun getAllOutboundTags(): MutableList {
+ if (configType != EConfigType.CUSTOM) {
+ return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
+ }
+ fullConfig?.let { config ->
+ return config.outbounds.map { it.tag }.toMutableList()
+ }
+ return mutableListOf()
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServersCache.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServersCache.kt
new file mode 100644
index 00000000..7ea3e6a3
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/ServersCache.kt
@@ -0,0 +1,6 @@
+package com.v2ray.ang.dto
+
+data class ServersCache(
+ val guid: String,
+ val profile: ProfileItem
+)
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/SubscriptionItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/SubscriptionItem.kt
new file mode 100644
index 00000000..8957df78
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/SubscriptionItem.kt
@@ -0,0 +1,16 @@
+package com.v2ray.ang.dto
+
+data class SubscriptionItem(
+ var remarks: String = "",
+ var url: String = "",
+ var enabled: Boolean = true,
+ val addedTime: Long = System.currentTimeMillis(),
+ var lastUpdated: Long = -1,
+ var autoUpdate: Boolean = false,
+ val updateInterval: Int? = null,
+ var prevProfile: String? = null,
+ var nextProfile: String? = null,
+ var filter: String? = null,
+ var allowInsecureUrl: Boolean = false,
+)
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt
new file mode 100644
index 00000000..155be104
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/V2rayConfig.kt
@@ -0,0 +1,556 @@
+package com.v2ray.ang.dto
+
+import com.google.gson.annotations.SerializedName
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.util.Utils
+
+data class V2rayConfig(
+ var remarks: String? = null,
+ var stats: Any? = null,
+ val log: LogBean,
+ var policy: PolicyBean? = null,
+ val inbounds: ArrayList,
+ var outbounds: ArrayList,
+ var dns: DnsBean? = null,
+ val routing: RoutingBean,
+ val api: Any? = null,
+ val transport: Any? = null,
+ val reverse: Any? = null,
+ var fakedns: Any? = null,
+ val browserForwarder: Any? = null,
+ var observatory: Any? = null,
+ var burstObservatory: Any? = null
+) {
+
+ data class LogBean(
+ val access: String? = null,
+ val error: String? = null,
+ var loglevel: String? = null,
+ val dnsLog: Boolean? = null
+ )
+
+ data class InboundBean(
+ var tag: String,
+ var port: Int,
+ var protocol: String,
+ var listen: String? = null,
+ val settings: Any? = null,
+ val sniffing: SniffingBean? = null,
+ val streamSettings: Any? = null,
+ val allocate: Any? = null
+ ) {
+
+ data class InSettingsBean(
+ val auth: String? = null,
+ val udp: Boolean? = null,
+ val userLevel: Int? = null,
+ val address: String? = null,
+ val port: Int? = null,
+ val network: String? = null
+ )
+
+ data class SniffingBean(
+ var enabled: Boolean,
+ val destOverride: ArrayList,
+ val metadataOnly: Boolean? = null,
+ var routeOnly: Boolean? = null
+ )
+ }
+
+ data class OutboundBean(
+ var tag: String = "proxy",
+ var protocol: String,
+ var settings: OutSettingsBean? = null,
+ var streamSettings: StreamSettingsBean? = null,
+ val proxySettings: Any? = null,
+ val sendThrough: String? = null,
+ var mux: MuxBean? = MuxBean(false)
+ ) {
+ data class OutSettingsBean(
+ var vnext: List? = null,
+ var fragment: FragmentBean? = null,
+ var noises: List? = null,
+ var servers: List? = null,
+ /*Blackhole*/
+ var response: Response? = null,
+ /*DNS*/
+ val network: String? = null,
+ var address: Any? = null,
+ val port: Int? = null,
+ /*Freedom*/
+ var domainStrategy: String? = null,
+ val redirect: String? = null,
+ val userLevel: Int? = null,
+ /*Loopback*/
+ val inboundTag: String? = null,
+ /*Wireguard*/
+ var secretKey: String? = null,
+ val peers: List? = null,
+ var reserved: List? = null,
+ var mtu: Int? = null,
+ var obfsPassword: String? = null,
+ ) {
+
+ data class VnextBean(
+ var address: String = "",
+ var port: Int = AppConfig.DEFAULT_PORT,
+ var users: List
+ ) {
+
+ data class UsersBean(
+ var id: String = "",
+ var alterId: Int? = null,
+ var security: String? = null,
+ var level: Int = AppConfig.DEFAULT_LEVEL,
+ var encryption: String? = null,
+ var flow: String? = null
+ )
+ }
+
+ data class FragmentBean(
+ var packets: String? = null,
+ var length: String? = null,
+ var interval: String? = null
+ )
+
+ data class NoiseBean(
+ var type: String? = null,
+ var packet: String? = null,
+ var delay: String? = null
+ )
+
+ data class ServersBean(
+ var address: String = "",
+ var method: String? = null,
+ var ota: Boolean = false,
+ var password: String? = null,
+ var port: Int = AppConfig.DEFAULT_PORT,
+ var level: Int = AppConfig.DEFAULT_LEVEL,
+ val email: String? = null,
+ var flow: String? = null,
+ val ivCheck: Boolean? = null,
+ var users: List? = null
+ ) {
+ data class SocksUsersBean(
+ var user: String = "",
+ var pass: String = "",
+ var level: Int = AppConfig.DEFAULT_LEVEL
+ )
+ }
+
+ data class Response(var type: String)
+
+ data class WireGuardBean(
+ var publicKey: String = "",
+ var preSharedKey: String? = null,
+ var endpoint: String = ""
+ )
+ }
+
+ data class StreamSettingsBean(
+ var network: String = AppConfig.DEFAULT_NETWORK,
+ var security: String? = null,
+ var tcpSettings: TcpSettingsBean? = null,
+ var kcpSettings: KcpSettingsBean? = null,
+ var wsSettings: WsSettingsBean? = null,
+ var httpupgradeSettings: HttpupgradeSettingsBean? = null,
+ var xhttpSettings: XhttpSettingsBean? = null,
+ var httpSettings: HttpSettingsBean? = null,
+ var tlsSettings: TlsSettingsBean? = null,
+ var quicSettings: QuicSettingBean? = null,
+ var realitySettings: TlsSettingsBean? = null,
+ var grpcSettings: GrpcSettingsBean? = null,
+ var hy2steriaSettings: Hy2steriaSettingsBean? = null,
+ val dsSettings: Any? = null,
+ var sockopt: SockoptBean? = null
+ ) {
+
+ data class TcpSettingsBean(
+ var header: HeaderBean = HeaderBean(),
+ val acceptProxyProtocol: Boolean? = null
+ ) {
+ data class HeaderBean(
+ var type: String = "none",
+ var request: RequestBean? = null,
+ var response: Any? = null
+ ) {
+ data class RequestBean(
+ var path: List = ArrayList(),
+ var headers: HeadersBean = HeadersBean(),
+ val version: String? = null,
+ val method: String? = null
+ ) {
+ data class HeadersBean(
+ var Host: List? = ArrayList(),
+ @SerializedName("User-Agent")
+ val userAgent: List? = null,
+ @SerializedName("Accept-Encoding")
+ val acceptEncoding: List? = null,
+ val Connection: List? = null,
+ val Pragma: String? = null
+ )
+ }
+ }
+ }
+
+ data class KcpSettingsBean(
+ var mtu: Int = 1350,
+ var tti: Int = 50,
+ var uplinkCapacity: Int = 12,
+ var downlinkCapacity: Int = 100,
+ var congestion: Boolean = false,
+ var readBufferSize: 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(
+ var path: String? = null,
+ var headers: HeadersBean = HeadersBean(),
+ val maxEarlyData: Int? = null,
+ val useBrowserForwarding: Boolean? = null,
+ val acceptProxyProtocol: Boolean? = null
+ ) {
+ data class HeadersBean(var Host: String = "")
+ }
+
+ data class HttpupgradeSettingsBean(
+ var path: String? = null,
+ var host: String? = null,
+ val acceptProxyProtocol: Boolean? = null
+ )
+
+ data class XhttpSettingsBean(
+ var path: String? = null,
+ var host: String? = null,
+ var mode: String? = null,
+ var extra: Any? = null,
+ )
+
+ data class HttpSettingsBean(
+ var host: List = ArrayList(),
+ var path: String? = null
+ )
+
+ data class SockoptBean(
+ var TcpNoDelay: Boolean? = null,
+ var tcpKeepAliveIdle: Int? = null,
+ var tcpFastOpen: Boolean? = null,
+ var tproxy: String? = null,
+ var mark: Int? = null,
+ var dialerProxy: String? = null,
+ var domainStrategy: String? = null
+ )
+
+ data class TlsSettingsBean(
+ var allowInsecure: Boolean = false,
+ var serverName: String? = null,
+ val alpn: List? = null,
+ val minVersion: String? = null,
+ val maxVersion: String? = null,
+ val preferServerCipherSuites: Boolean? = null,
+ val cipherSuites: String? = null,
+ val fingerprint: String? = null,
+ val certificates: List? = null,
+ val disableSystemRoot: Boolean? = null,
+ val enableSessionResumption: Boolean? = null,
+ // REALITY settings
+ val show: Boolean = false,
+ var publicKey: String? = null,
+ var shortId: String? = null,
+ var spiderX: String? = null
+ )
+
+ data class QuicSettingBean(
+ var security: String = "none",
+ var key: String = "",
+ var header: HeaderBean = HeaderBean()
+ ) {
+ data class HeaderBean(var type: String = "none")
+ }
+
+ data class GrpcSettingsBean(
+ var serviceName: String = "",
+ var authority: String? = null,
+ var multiMode: Boolean? = null,
+ var idle_timeout: Int? = null,
+ var health_check_timeout: Int? = null
+ )
+
+ data class Hy2steriaSettingsBean(
+ var password: String? = null,
+ var use_udp_extension: Boolean? = true,
+ var congestion: Hy2CongestionBean? = null
+ ) {
+ data class Hy2CongestionBean(
+ var type: String? = "bbr",
+ var up_mbps: Int? = null,
+ var down_mbps: Int? = null,
+ )
+ }
+
+ }
+
+ data class MuxBean(
+ var enabled: Boolean,
+ var concurrency: Int? = null,
+ var xudpConcurrency: Int? = null,
+ var xudpProxyUDP443: String? = null,
+ )
+
+ fun getServerAddress(): String? {
+ if (protocol.equals(EConfigType.VMESS.name, true)
+ || protocol.equals(EConfigType.VLESS.name, true)
+ ) {
+ return settings?.vnext?.first()?.address
+ } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+ || protocol.equals(EConfigType.SOCKS.name, true)
+ || protocol.equals(EConfigType.HTTP.name, true)
+ || protocol.equals(EConfigType.TROJAN.name, true)
+ || protocol.equals(EConfigType.HYSTERIA2.name, true)
+ ) {
+ return settings?.servers?.first()?.address
+ } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+ return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
+ }
+ return null
+ }
+
+ fun getServerPort(): Int? {
+ if (protocol.equals(EConfigType.VMESS.name, true)
+ || protocol.equals(EConfigType.VLESS.name, true)
+ ) {
+ return settings?.vnext?.first()?.port
+ } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+ || protocol.equals(EConfigType.SOCKS.name, true)
+ || protocol.equals(EConfigType.HTTP.name, true)
+ || protocol.equals(EConfigType.TROJAN.name, true)
+ || protocol.equals(EConfigType.HYSTERIA2.name, true)
+ ) {
+ return settings?.servers?.first()?.port
+ } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+ return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
+ }
+ return null
+ }
+
+ 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
+ } 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? {
+ 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
+ }
+
+ fun ensureSockopt(): V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean {
+ val stream = streamSettings ?: V2rayConfig.OutboundBean.StreamSettingsBean().also {
+ streamSettings = it
+ }
+
+ val sockopt = stream.sockopt ?: V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean().also {
+ stream.sockopt = it
+ }
+
+ return sockopt
+ }
+ }
+
+ data class DnsBean(
+ var servers: ArrayList? = null,
+ var hosts: Map? = null,
+ val clientIp: String? = null,
+ val disableCache: Boolean? = null,
+ val queryStrategy: String? = null,
+ val tag: String? = null
+ ) {
+ data class ServersBean(
+ var address: String = "",
+ var port: Int? = null,
+ var domains: List? = null,
+ var expectIPs: List? = null,
+ val clientIp: String? = null,
+ val skipFallback: Boolean? = null,
+ )
+ }
+
+ data class RoutingBean(
+ var domainStrategy: String,
+ var domainMatcher: String? = null,
+ var rules: ArrayList,
+ val balancers: List? = null
+ ) {
+
+ data class RulesBean(
+ var type: String = "field",
+ var ip: ArrayList? = null,
+ var domain: ArrayList? = null,
+ var outboundTag: String = "",
+ var balancerTag: String? = null,
+ var port: String? = null,
+ val sourcePort: String? = null,
+ val network: String? = null,
+ val source: List? = null,
+ val user: List? = null,
+ var inboundTag: List? = null,
+ val protocol: List? = null,
+ val attrs: String? = null,
+ val domainMatcher: String? = null
+ )
+ }
+
+ data class PolicyBean(
+ var levels: Map,
+ var system: Any? = null
+ ) {
+ data class LevelBean(
+ var handshake: Int? = null,
+ var connIdle: Int? = null,
+ var uplinkOnly: Int? = null,
+ var downlinkOnly: Int? = null,
+ val statsUserUplink: Boolean? = null,
+ val statsUserDownlink: Boolean? = null,
+ var bufferSize: Int? = null
+ )
+ }
+
+ data class FakednsBean(
+ var ipPool: String = "198.18.0.0/15",
+ var poolSize: Int = 10000
+ ) // roughly 10 times smaller than total ip pool
+
+ fun getProxyOutbound(): OutboundBean? {
+ outbounds.forEach { outbound ->
+ EConfigType.entries.forEach {
+ if (outbound.protocol.equals(it.name, true)) {
+ return outbound
+ }
+ }
+ }
+ return null
+ }
+
+ fun getAllProxyOutbound(): List {
+ return outbounds.filter { outbound ->
+ EConfigType.entries.any { it.name.equals(outbound.protocol, ignoreCase = true) }
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VmessQRCode.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VmessQRCode.kt
new file mode 100644
index 00000000..779c7e3c
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VmessQRCode.kt
@@ -0,0 +1,19 @@
+package com.v2ray.ang.dto
+
+data class VmessQRCode(
+ var v: String = "",
+ var ps: String = "",
+ var add: String = "",
+ var port: String = "",
+ var id: String = "",
+ var aid: String = "0",
+ var scy: String = "",
+ var net: String = "",
+ var type: String = "",
+ var host: String = "",
+ var path: String = "",
+ var tls: String = "",
+ var sni: String = "",
+ var alpn: String = "",
+ var fp: String = ""
+)
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VpnInterfaceAddressConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VpnInterfaceAddressConfig.kt
new file mode 100644
index 00000000..6b7bc379
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/VpnInterfaceAddressConfig.kt
@@ -0,0 +1,39 @@
+package com.v2ray.ang.dto
+
+/**
+ * VPN interface address configuration enum class
+ * Defines predefined IPv4 and IPv6 address pairs for VPN TUN interface configuration.
+ * Each option provides client and router addresses to establish point-to-point VPN tunnels.
+ */
+enum class VpnInterfaceAddressConfig(
+ val displayName: String,
+ val ipv4Client: String,
+ val ipv4Router: String,
+ val ipv6Client: String,
+ val ipv6Router: String
+) {
+ OPTION_1("10.10.14.x", "10.10.14.1", "10.10.14.2", "fc00::10:10:14:1", "fc00::10:10:14:2"),
+ OPTION_2("10.1.0.x", "10.1.0.1", "10.1.0.2", "fc00::10:1:0:1", "fc00::10:1:0:2"),
+ OPTION_3("10.0.0.x", "10.0.0.1", "10.0.0.2", "fc00::10:0:0:1", "fc00::10:0:0:2"),
+ OPTION_4("172.31.0.x", "172.31.0.1", "172.31.0.2", "fc00::172:31:0:1", "fc00::172:31:0:2"),
+ OPTION_5("172.20.0.x", "172.20.0.1", "172.20.0.2", "fc00::172:20:0:1", "fc00::172:20:0:2"),
+ OPTION_6("172.16.0.x", "172.16.0.1", "172.16.0.2", "fc00::172:16:0:1", "fc00::172:16:0:2"),
+ OPTION_7("192.168.100.x", "192.168.100.1", "192.168.100.2", "fc00::192:168:100:1", "fc00::192:168:100:2");
+
+ companion object {
+ /**
+ * Retrieves the VPN interface address configuration based on the specified index.
+ *
+ * @param index The configuration index (0-based) corresponding to user selection
+ * @return The VpnInterfaceAddressConfig instance at the specified index,
+ * or OPTION_1 (default) if the index is out of bounds
+ */
+ fun getConfigByIndex(index: Int): VpnInterfaceAddressConfig {
+ return if (index in values().indices) {
+ values()[index]
+ } else {
+ OPTION_1 // Default to the first configuration
+ }
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt
new file mode 100644
index 00000000..6e5c6bb4
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt
@@ -0,0 +1,212 @@
+package com.v2ray.ang.extension
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import com.v2ray.ang.AngApplication
+import es.dmoral.toasty.Toasty
+import org.json.JSONObject
+import java.io.Serializable
+import java.net.URI
+import java.net.URLConnection
+
+val Context.v2RayApplication: AngApplication?
+ get() = applicationContext as? AngApplication
+
+/**
+ * Shows a toast message with the given resource ID.
+ *
+ * @param message The resource ID of the message to show.
+ */
+fun Context.toast(message: Int) {
+ Toasty.normal(this, message).show()
+}
+
+/**
+ * Shows a toast message with the given text.
+ *
+ * @param message The text of the message to show.
+ */
+fun Context.toast(message: CharSequence) {
+ Toasty.normal(this, message).show()
+}
+
+/**
+ * Shows a toast message with the given resource ID.
+ *
+ * @param message The resource ID of the message to show.
+ */
+fun Context.toastSuccess(message: Int) {
+ Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
+}
+
+/**
+ * Shows a toast message with the given text.
+ *
+ * @param message The text of the message to show.
+ */
+fun Context.toastSuccess(message: CharSequence) {
+ Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
+}
+
+/**
+ * Shows a toast message with the given resource ID.
+ *
+ * @param message The resource ID of the message to show.
+ */
+fun Context.toastError(message: Int) {
+ Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
+}
+
+/**
+ * Shows a toast message with the given text.
+ *
+ * @param message The text of the message to show.
+ */
+fun Context.toastError(message: CharSequence) {
+ Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
+}
+
+
+/**
+ * Puts a key-value pair into the JSONObject.
+ *
+ * @param pair The key-value pair to put.
+ */
+fun JSONObject.putOpt(pair: Pair) {
+ put(pair.first, pair.second)
+}
+
+/**
+ * Puts multiple key-value pairs into the JSONObject.
+ *
+ * @param pairs The map of key-value pairs to put.
+ */
+fun JSONObject.putOpt(pairs: Map) {
+ pairs.forEach { put(it.key, it.value) }
+}
+
+const val THRESHOLD = 1000L
+const val DIVISOR = 1024.0
+
+/**
+ * Converts a Long value to a speed string.
+ *
+ * @return The speed string.
+ */
+fun Long.toSpeedString(): String = this.toTrafficString() + "/s"
+
+/**
+ * Converts a Long value to a traffic string.
+ *
+ * @return The traffic string.
+ */
+fun Long.toTrafficString(): String {
+ val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
+ var size = this.toDouble()
+ var unitIndex = 0
+ while (size >= THRESHOLD && unitIndex < units.size - 1) {
+ size /= DIVISOR
+ unitIndex++
+ }
+ return String.format("%.1f %s", size, units[unitIndex])
+}
+
+val URLConnection.responseLength: Long
+ get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ contentLengthLong
+ } else {
+ contentLength.toLong()
+ }
+
+val URI.idnHost: String
+ get() = host?.replace("[", "")?.replace("]", "").orEmpty()
+
+/**
+ * Removes all whitespace from the string.
+ *
+ * @return The string without whitespace.
+ */
+fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
+
+/**
+ * Converts the string to a Long value, or returns 0 if the conversion fails.
+ *
+ * @return The Long value.
+ */
+fun String.toLongEx(): Long = toLongOrNull() ?: 0
+
+/**
+ * Listens for package changes and executes a callback when a change occurs.
+ *
+ * @param onetime Whether to unregister the receiver after the first callback.
+ * @param callback The callback to execute when a package change occurs.
+ * @return The BroadcastReceiver that was registered.
+ */
+fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ callback()
+ if (onetime) context.unregisterReceiver(this)
+ }
+ }.apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(this, IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addDataScheme("package")
+ }, Context.RECEIVER_EXPORTED)
+ } else {
+ registerReceiver(this, IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addDataScheme("package")
+ })
+ }
+ }
+
+/**
+ * Retrieves a serializable object from the Bundle.
+ *
+ * @param key The key of the serializable object.
+ * @return The serializable object, or null if not found.
+ */
+inline fun Bundle.serializable(key: String): T? = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializable(key, T::class.java)
+ else -> @Suppress("DEPRECATION") getSerializable(key) as? T
+}
+
+/**
+ * Retrieves a serializable object from the Intent.
+ *
+ * @param key The key of the serializable object.
+ * @return The serializable object, or null if not found.
+ */
+inline fun Intent.serializable(key: String): T? = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializableExtra(key, T::class.java)
+ else -> @Suppress("DEPRECATION") getSerializableExtra(key) as? T
+}
+
+/**
+ * Checks if the CharSequence is not null and not empty.
+ *
+ * @return True if the CharSequence is not null and not empty, false otherwise.
+ */
+fun CharSequence?.isNotNullEmpty(): Boolean = this != null && this.isNotEmpty()
+
+fun String.concatUrl(vararg paths: String): String {
+ val builder = StringBuilder(this.trimEnd('/'))
+
+ paths.forEach { path ->
+ val trimmedPath = path.trim('/')
+ if (trimmedPath.isNotEmpty()) {
+ builder.append('/').append(trimmedPath)
+ }
+ }
+
+ return builder.toString()
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt
new file mode 100644
index 00000000..3bc20927
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/CustomFmt.kt
@@ -0,0 +1,27 @@
+package com.v2ray.ang.fmt
+
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.V2rayConfig
+import com.v2ray.ang.util.JsonUtil
+
+object CustomFmt : FmtBase() {
+ /**
+ * Parses a JSON string into a ProfileItem object.
+ *
+ * @param str the JSON string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parse(str: String): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.CUSTOM)
+
+ val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
+ val outbound = fullConfig.getProxyOutbound()
+
+ config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
+ config.server = outbound?.getServerAddress()
+ config.serverPort = outbound?.getServerPort().toString()
+
+ return config
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt
new file mode 100644
index 00000000..73cdf958
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt
@@ -0,0 +1,170 @@
+package com.v2ray.ang.fmt
+
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.dto.NetworkType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+open class FmtBase {
+ /**
+ * Converts a ProfileItem object to a URI string.
+ *
+ * @param config the ProfileItem object to convert
+ * @param userInfo the user information to include in the URI
+ * @param dicQuery the query parameters to include in the URI
+ * @return the converted URI string
+ */
+ fun toUri(config: ProfileItem, userInfo: String?, dicQuery: HashMap?): String {
+ val query = if (dicQuery != null)
+ "?" + dicQuery.toList().joinToString(
+ separator = "&",
+ transform = { it.first + "=" + Utils.urlEncode(it.second) })
+ else ""
+
+ val url = String.format(
+ "%s@%s:%s",
+ Utils.urlEncode(userInfo ?: ""),
+ Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
+ config.serverPort
+ )
+
+ return "${url}${query}#${Utils.urlEncode(config.remarks)}"
+ }
+
+ /**
+ * Extracts query parameters from a URI.
+ *
+ * @param uri the URI to extract query parameters from
+ * @return a map of query parameters
+ */
+ fun getQueryParam(uri: URI): Map {
+ return uri.rawQuery.split("&")
+ .associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
+ }
+
+ /**
+ * Populates a ProfileItem object with values from query parameters.
+ *
+ * @param config the ProfileItem object to populate
+ * @param queryParam the query parameters to use for populating the ProfileItem
+ * @param allowInsecure whether to allow insecure connections
+ */
+ fun getItemFormQuery(config: ProfileItem, queryParam: Map, allowInsecure: Boolean) {
+ config.network = queryParam["type"] ?: NetworkType.TCP.type
+ config.headerType = queryParam["headerType"]
+ config.host = queryParam["host"]
+ config.path = queryParam["path"]
+
+ config.seed = queryParam["seed"]
+ config.quicSecurity = queryParam["quicSecurity"]
+ config.quicKey = queryParam["key"]
+ config.mode = queryParam["mode"]
+ config.serviceName = queryParam["serviceName"]
+ config.authority = queryParam["authority"]
+ config.xhttpMode = queryParam["mode"]
+ config.xhttpExtra = queryParam["extra"]
+
+ config.security = queryParam["security"]
+ if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
+ config.security = null
+ }
+ config.insecure = if (queryParam["allowInsecure"].isNullOrEmpty()) {
+ allowInsecure
+ } else {
+ queryParam["allowInsecure"].orEmpty() == "1"
+ }
+ config.sni = queryParam["sni"]
+ config.fingerPrint = queryParam["fp"]
+ config.alpn = queryParam["alpn"]
+ config.publicKey = queryParam["pbk"]
+ config.shortId = queryParam["sid"]
+ config.spiderX = queryParam["spx"]
+ config.flow = queryParam["flow"]
+ }
+
+ /**
+ * Creates a map of query parameters from a ProfileItem object.
+ *
+ * @param config the ProfileItem object to create query parameters from
+ * @return a map of query parameters
+ */
+ fun getQueryDic(config: ProfileItem): HashMap {
+ val dicQuery = HashMap()
+ dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
+ config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
+ config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
+ config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() }
+ config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() }
+ config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() }
+ config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() }
+ config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() }
+
+ val networkType = NetworkType.fromString(config.network)
+ dicQuery["type"] = networkType.type
+
+ when (networkType) {
+ NetworkType.TCP -> {
+ dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
+ config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
+ }
+
+ NetworkType.KCP -> {
+ dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
+ config.seed.let { if (it.isNotNullEmpty()) dicQuery["seed"] = it.orEmpty() }
+ }
+
+ NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
+ config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
+ config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
+ }
+
+ NetworkType.XHTTP -> {
+ config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
+ config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
+ config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
+ config.xhttpExtra.let { if (it.isNotNullEmpty()) dicQuery["extra"] = it.orEmpty() }
+ }
+
+ NetworkType.HTTP, NetworkType.H2 -> {
+ dicQuery["type"] = "http"
+ config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
+ config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
+ }
+
+// NetworkType.QUIC -> {
+// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
+// config.quicSecurity.let { if (it.isNotNullEmpty()) dicQuery["quicSecurity"] = it.orEmpty() }
+// config.quicKey.let { if (it.isNotNullEmpty()) dicQuery["key"] = it.orEmpty() }
+// }
+
+ NetworkType.GRPC -> {
+ config.mode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
+ config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() }
+ config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() }
+ }
+ }
+
+ return dicQuery
+ }
+
+ fun getServerAddress(profileItem: ProfileItem): String {
+ if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
+ return profileItem.server.orEmpty()
+ }
+
+ val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
+ if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
+ return domain
+ }
+ //Resolve and replace domain
+ val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
+ if (resolvedIps.isNullOrEmpty()) {
+ return domain
+ }
+ return resolvedIps.first()
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/HttpFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/HttpFmt.kt
new file mode 100644
index 00000000..8c641f24
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/HttpFmt.kt
@@ -0,0 +1,32 @@
+package com.v2ray.ang.fmt
+
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.V2rayConfig.OutboundBean
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.handler.V2rayConfigManager
+
+object HttpFmt : FmtBase() {
+ /**
+ * Converts a ProfileItem object to an OutboundBean object.
+ *
+ * @param profileItem the ProfileItem object to convert
+ * @return the converted OutboundBean object, or null if conversion fails
+ */
+ fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+ val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
+
+ outboundBean?.settings?.servers?.first()?.let { server ->
+ server.address = getServerAddress(profileItem)
+ server.port = profileItem.serverPort.orEmpty().toInt()
+ if (profileItem.username.isNotNullEmpty()) {
+ val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
+ socksUsersBean.user = profileItem.username.orEmpty()
+ socksUsersBean.pass = profileItem.password.orEmpty()
+ server.users = listOf(socksUsersBean)
+ }
+ }
+
+ return outboundBean
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt
new file mode 100644
index 00000000..3b3dc88c
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/Hysteria2Fmt.kt
@@ -0,0 +1,151 @@
+package com.v2ray.ang.fmt
+
+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.V2rayConfig.OutboundBean
+import com.v2ray.ang.extension.idnHost
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object Hysteria2Fmt : FmtBase() {
+ /**
+ * Parses a Hysteria2 URI string into a ProfileItem object.
+ *
+ * @param str the Hysteria2 URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parse(str: String): ProfileItem? {
+ var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+ val config = ProfileItem.create(EConfigType.HYSTERIA2)
+
+ val uri = URI(Utils.fixIllegalUrl(str))
+ config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
+ config.server = uri.idnHost
+ config.serverPort = uri.port.toString()
+ config.password = uri.userInfo
+ config.security = AppConfig.TLS
+
+ if (!uri.rawQuery.isNullOrEmpty()) {
+ val queryParam = getQueryParam(uri)
+
+ config.security = queryParam["security"] ?: AppConfig.TLS
+ config.insecure = if (queryParam["insecure"].isNullOrEmpty()) {
+ allowInsecure
+ } else {
+ queryParam["insecure"].orEmpty() == "1"
+ }
+ config.sni = queryParam["sni"]
+ config.alpn = queryParam["alpn"]
+
+ config.obfsPassword = queryParam["obfs-password"]
+ config.portHopping = queryParam["mport"]
+ config.pinSHA256 = queryParam["pinSHA256"]
+
+ }
+
+ return config
+ }
+
+ /**
+ * Converts a ProfileItem object to a URI string.
+ *
+ * @param config the ProfileItem object to convert
+ * @return the converted URI string
+ */
+ fun toUri(config: ProfileItem): String {
+ val dicQuery = HashMap()
+
+ config.security.let { if (it != null) dicQuery["security"] = it }
+ config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
+ config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
+ config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
+
+ if (config.obfsPassword.isNotNullEmpty()) {
+ dicQuery["obfs"] = "salamander"
+ dicQuery["obfs-password"] = config.obfsPassword.orEmpty()
+ }
+ if (config.portHopping.isNotNullEmpty()) {
+ dicQuery["mport"] = config.portHopping.orEmpty()
+ }
+ if (config.pinSHA256.isNotNullEmpty()) {
+ dicQuery["pinSHA256"] = config.pinSHA256.orEmpty()
+ }
+
+ 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 ?: "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.
+ *
+ * @param profileItem the ProfileItem object to convert
+ * @return the converted OutboundBean object, or null if conversion fails
+ */
+ fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+ val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2)
+ return outboundBean
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt
new file mode 100644
index 00000000..87ba74f8
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt
@@ -0,0 +1,154 @@
+package com.v2ray.ang.fmt
+
+import android.util.Log
+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.V2rayConfig.OutboundBean
+import com.v2ray.ang.extension.idnHost
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object ShadowsocksFmt : FmtBase() {
+ /**
+ * Parses a Shadowsocks URI string into a ProfileItem object.
+ *
+ * @param str the Shadowsocks URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parse(str: String): ProfileItem? {
+ return parseSip002(str) ?: parseLegacy(str)
+ }
+
+ /**
+ * Parses a SIP002 Shadowsocks URI string into a ProfileItem object.
+ *
+ * @param str the SIP002 Shadowsocks URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parseSip002(str: String): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.SHADOWSOCKS)
+
+ val uri = URI(Utils.fixIllegalUrl(str))
+ if (uri.idnHost.isEmpty()) return null
+ if (uri.port <= 0) return null
+ if (uri.userInfo.isNullOrEmpty()) return null
+
+ config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
+ config.server = uri.idnHost
+ config.serverPort = uri.port.toString()
+
+ val result = if (uri.userInfo.contains(":")) {
+ uri.userInfo.split(":", limit = 2)
+ } else {
+ Utils.decode(uri.userInfo).split(":", limit = 2)
+ }
+ if (result.count() == 2) {
+ config.method = result.first()
+ config.password = result.last()
+ }
+
+ if (!uri.rawQuery.isNullOrEmpty()) {
+ val queryParam = getQueryParam(uri)
+ if (queryParam["plugin"]?.contains("obfs=http") == true) {
+ val queryPairs = HashMap()
+ for (pair in queryParam["plugin"]?.split(";") ?: listOf()) {
+ val idx = pair.split("=")
+ if (idx.count() == 2) {
+ queryPairs.put(idx.first(), idx.last())
+ }
+ }
+ config.network = NetworkType.TCP.type
+ config.headerType = "http"
+ config.host = queryPairs["obfs-host"]
+ config.path = queryPairs["path"]
+ }
+ }
+
+ return config
+ }
+
+ /**
+ * Parses a legacy Shadowsocks URI string into a ProfileItem object.
+ *
+ * @param str the legacy Shadowsocks URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parseLegacy(str: String): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.SHADOWSOCKS)
+ var result = str.replace(EConfigType.SHADOWSOCKS.protocolScheme, "")
+ val indexSplit = result.indexOf("#")
+ if (indexSplit > 0) {
+ try {
+ config.remarks =
+ Utils.urlDecode(result.substring(indexSplit + 1, result.length))
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
+ }
+
+ result = result.substring(0, indexSplit)
+ }
+
+ //part decode
+ val indexS = result.indexOf("@")
+ result = if (indexS > 0) {
+ Utils.decode(result.substring(0, indexS)) + result.substring(
+ indexS,
+ result.length
+ )
+ } else {
+ Utils.decode(result)
+ }
+
+ val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)/?$".toRegex()
+ val match = legacyPattern.matchEntire(result) ?: return null
+
+ config.server = match.groupValues[3].removeSurrounding("[", "]")
+ config.serverPort = match.groupValues[4]
+ config.password = match.groupValues[2]
+ config.method = match.groupValues[1].lowercase()
+
+ return config
+ }
+
+ /**
+ * Converts a ProfileItem object to a URI string.
+ *
+ * @param config the ProfileItem object to convert
+ * @return the converted URI string
+ */
+ fun toUri(config: ProfileItem): String {
+ val pw = "${config.method}:${config.password}"
+
+ return toUri(config, Utils.encode(pw), null)
+ }
+
+ /**
+ * Converts a ProfileItem object to an OutboundBean object.
+ *
+ * @param profileItem the ProfileItem object to convert
+ * @return the converted OutboundBean object, or null if conversion fails
+ */
+ fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+ val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
+
+ outboundBean?.settings?.servers?.first()?.let { server ->
+ server.address = getServerAddress(profileItem)
+ server.port = profileItem.serverPort.orEmpty().toInt()
+ server.password = profileItem.password
+ server.method = profileItem.method
+ }
+
+ val sni = outboundBean?.streamSettings?.let {
+ V2rayConfigManager.populateTransportSettings(it, profileItem)
+ }
+
+ outboundBean?.streamSettings?.let {
+ V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
+ }
+
+ return outboundBean
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/SocksFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/SocksFmt.kt
new file mode 100644
index 00000000..30bc08e4
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/SocksFmt.kt
@@ -0,0 +1,79 @@
+package com.v2ray.ang.fmt
+
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.V2rayConfig.OutboundBean
+import com.v2ray.ang.extension.idnHost
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object SocksFmt : FmtBase() {
+ /**
+ * Parses a Socks URI string into a ProfileItem object.
+ *
+ * @param str the Socks URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parse(str: String): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.SOCKS)
+
+ val uri = URI(Utils.fixIllegalUrl(str))
+ if (uri.idnHost.isEmpty()) return null
+ if (uri.port <= 0) return null
+
+ config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
+ config.server = uri.idnHost
+ config.serverPort = uri.port.toString()
+
+ if (uri.userInfo?.isEmpty() == false) {
+ val result = Utils.decode(uri.userInfo).split(":", limit = 2)
+ if (result.count() == 2) {
+ config.username = result.first()
+ config.password = result.last()
+ }
+ }
+
+ return config
+ }
+
+ /**
+ * Converts a ProfileItem object to a URI string.
+ *
+ * @param config the ProfileItem object to convert
+ * @return the converted URI string
+ */
+ fun toUri(config: ProfileItem): String {
+ val pw =
+ if (config.username.isNotNullEmpty())
+ "${config.username}:${config.password}"
+ else
+ ":"
+
+ return toUri(config, Utils.encode(pw), null)
+ }
+
+ /**
+ * Converts a ProfileItem object to an OutboundBean object.
+ *
+ * @param profileItem the ProfileItem object to convert
+ * @return the converted OutboundBean object, or null if conversion fails
+ */
+ fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+ val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
+
+ outboundBean?.settings?.servers?.first()?.let { server ->
+ server.address = getServerAddress(profileItem)
+ server.port = profileItem.serverPort.orEmpty().toInt()
+ if (profileItem.username.isNotNullEmpty()) {
+ val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
+ socksUsersBean.user = profileItem.username.orEmpty()
+ socksUsersBean.pass = profileItem.password.orEmpty()
+ server.users = listOf(socksUsersBean)
+ }
+ }
+
+ return outboundBean
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt
new file mode 100644
index 00000000..446ef99c
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/TrojanFmt.kt
@@ -0,0 +1,83 @@
+package com.v2ray.ang.fmt
+
+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.V2rayConfig.OutboundBean
+import com.v2ray.ang.extension.idnHost
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object TrojanFmt : FmtBase() {
+ /**
+ * Parses a Trojan URI string into a ProfileItem object.
+ *
+ * @param str the Trojan URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parse(str: String): ProfileItem? {
+ var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+ val config = ProfileItem.create(EConfigType.TROJAN)
+
+ val uri = URI(Utils.fixIllegalUrl(str))
+ config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
+ config.server = uri.idnHost
+ config.serverPort = uri.port.toString()
+ config.password = uri.userInfo
+
+ if (uri.rawQuery.isNullOrEmpty()) {
+ config.network = NetworkType.TCP.type
+ config.security = AppConfig.TLS
+ config.insecure = allowInsecure
+ } else {
+ val queryParam = getQueryParam(uri)
+
+ getItemFormQuery(config, queryParam, allowInsecure)
+ config.security = queryParam["security"] ?: AppConfig.TLS
+ }
+
+ return config
+ }
+
+ /**
+ * Converts a ProfileItem object to a URI string.
+ *
+ * @param config the ProfileItem object to convert
+ * @return the converted URI string
+ */
+ fun toUri(config: ProfileItem): String {
+ val dicQuery = getQueryDic(config)
+
+ return toUri(config, config.password, dicQuery)
+ }
+
+ /**
+ * Converts a ProfileItem object to an OutboundBean object.
+ *
+ * @param profileItem the ProfileItem object to convert
+ * @return the converted OutboundBean object, or null if conversion fails
+ */
+ fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+ val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
+
+ outboundBean?.settings?.servers?.first()?.let { server ->
+ server.address = getServerAddress(profileItem)
+ server.port = profileItem.serverPort.orEmpty().toInt()
+ server.password = profileItem.password
+ server.flow = profileItem.flow
+ }
+
+ val sni = outboundBean?.streamSettings?.let {
+ V2rayConfigManager.populateTransportSettings(it, profileItem)
+ }
+
+ outboundBean?.streamSettings?.let {
+ V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
+ }
+
+ return outboundBean
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VlessFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VlessFmt.kt
new file mode 100644
index 00000000..9242f0ec
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VlessFmt.kt
@@ -0,0 +1,80 @@
+package com.v2ray.ang.fmt
+
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.V2rayConfig.OutboundBean
+import com.v2ray.ang.extension.idnHost
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object VlessFmt : FmtBase() {
+
+ /**
+ * Parses a Vless URI string into a ProfileItem object.
+ *
+ * @param str the Vless URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parse(str: String): ProfileItem? {
+ var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+ val config = ProfileItem.create(EConfigType.VLESS)
+
+ val uri = URI(Utils.fixIllegalUrl(str))
+ if (uri.rawQuery.isNullOrEmpty()) return null
+ val queryParam = getQueryParam(uri)
+
+ config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
+ config.server = uri.idnHost
+ config.serverPort = uri.port.toString()
+ config.password = uri.userInfo
+ config.method = queryParam["encryption"] ?: "none"
+
+ getItemFormQuery(config, queryParam, allowInsecure)
+
+ return config
+ }
+
+ /**
+ * Converts a ProfileItem object to a URI string.
+ *
+ * @param config the ProfileItem object to convert
+ * @return the converted URI string
+ */
+ fun toUri(config: ProfileItem): String {
+ val dicQuery = getQueryDic(config)
+ dicQuery["encryption"] = config.method ?: "none"
+
+ return toUri(config, config.password, dicQuery)
+ }
+
+ /**
+ * Converts a ProfileItem object to an OutboundBean object.
+ *
+ * @param profileItem the ProfileItem object to convert
+ * @return the converted OutboundBean object, or null if conversion fails
+ */
+ fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+ val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
+
+ outboundBean?.settings?.vnext?.first()?.let { vnext ->
+ vnext.address = getServerAddress(profileItem)
+ vnext.port = profileItem.serverPort.orEmpty().toInt()
+ vnext.users[0].id = profileItem.password.orEmpty()
+ vnext.users[0].encryption = profileItem.method
+ vnext.users[0].flow = profileItem.flow
+ }
+
+ val sni = outboundBean?.streamSettings?.let {
+ V2rayConfigManager.populateTransportSettings(it, profileItem)
+ }
+
+ outboundBean?.streamSettings?.let {
+ V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
+ }
+
+ return outboundBean
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt
new file mode 100644
index 00000000..4201f4dc
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/VmessFmt.kt
@@ -0,0 +1,192 @@
+package com.v2ray.ang.fmt
+
+import android.text.TextUtils
+import android.util.Log
+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.V2rayConfig.OutboundBean
+import com.v2ray.ang.dto.VmessQRCode
+import com.v2ray.ang.extension.idnHost
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object VmessFmt : FmtBase() {
+ /**
+ * Parses a Vmess string into a ProfileItem object.
+ *
+ * @param str the Vmess string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parse(str: String): ProfileItem? {
+ if (str.indexOf('?') > 0 && str.indexOf('&') > 0) {
+ return parseVmessStd(str)
+ }
+
+ var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+ val config = ProfileItem.create(EConfigType.VMESS)
+
+ var result = str.replace(EConfigType.VMESS.protocolScheme, "")
+ result = Utils.decode(result)
+ if (TextUtils.isEmpty(result)) {
+ Log.w(AppConfig.TAG, "Toast decoding failed")
+ return null
+ }
+ val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java)
+ // Although VmessQRCode fields are non null, looks like Gson may still create null fields
+ if (TextUtils.isEmpty(vmessQRCode.add)
+ || TextUtils.isEmpty(vmessQRCode.port)
+ || TextUtils.isEmpty(vmessQRCode.id)
+ || TextUtils.isEmpty(vmessQRCode.net)
+ ) {
+ Log.w(AppConfig.TAG, "Toast incorrect protocol")
+ return null
+ }
+
+ config.remarks = vmessQRCode.ps
+ config.server = vmessQRCode.add
+ config.serverPort = vmessQRCode.port
+ config.password = vmessQRCode.id
+ config.method = if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
+
+ config.network = vmessQRCode.net ?: NetworkType.TCP.type
+ config.headerType = vmessQRCode.type
+ config.host = vmessQRCode.host
+ config.path = vmessQRCode.path
+
+ when (NetworkType.fromString(config.network)) {
+ NetworkType.KCP -> {
+ config.seed = vmessQRCode.path
+ }
+
+// NetworkType.QUIC -> {
+// config.quicSecurity = vmessQRCode.host
+// config.quicKey = vmessQRCode.path
+// }
+
+ NetworkType.GRPC -> {
+ config.mode = vmessQRCode.type
+ config.serviceName = vmessQRCode.path
+ config.authority = vmessQRCode.host
+ }
+
+ else -> {}
+ }
+
+ config.security = vmessQRCode.tls
+ config.insecure = allowInsecure
+ config.sni = vmessQRCode.sni
+ config.fingerPrint = vmessQRCode.fp
+ config.alpn = vmessQRCode.alpn
+
+ return config
+ }
+
+ /**
+ * Converts a ProfileItem object to a URI string.
+ *
+ * @param config the ProfileItem object to convert
+ * @return the converted URI string
+ */
+ fun toUri(config: ProfileItem): String {
+ val vmessQRCode = VmessQRCode()
+
+ vmessQRCode.v = "2"
+ vmessQRCode.ps = config.remarks
+ vmessQRCode.add = config.server.orEmpty()
+ vmessQRCode.port = config.serverPort.orEmpty()
+ vmessQRCode.id = config.password.orEmpty()
+ vmessQRCode.scy = config.method.orEmpty()
+ vmessQRCode.aid = "0"
+
+ vmessQRCode.net = config.network.orEmpty()
+ vmessQRCode.type = config.headerType.orEmpty()
+ when (NetworkType.fromString(config.network)) {
+ NetworkType.KCP -> {
+ vmessQRCode.path = config.seed.orEmpty()
+ }
+
+// NetworkType.QUIC -> {
+// vmessQRCode.host = config.quicSecurity.orEmpty()
+// vmessQRCode.path = config.quicKey.orEmpty()
+// }
+
+ NetworkType.GRPC -> {
+ vmessQRCode.type = config.mode.orEmpty()
+ vmessQRCode.path = config.serviceName.orEmpty()
+ vmessQRCode.host = config.authority.orEmpty()
+ }
+
+ else -> {}
+ }
+
+ config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() }
+ config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() }
+
+ vmessQRCode.tls = config.security.orEmpty()
+ vmessQRCode.sni = config.sni.orEmpty()
+ vmessQRCode.fp = config.fingerPrint.orEmpty()
+ vmessQRCode.alpn = config.alpn.orEmpty()
+
+ val json = JsonUtil.toJson(vmessQRCode)
+ return Utils.encode(json)
+ }
+
+ /**
+ * Parses a standard Vmess URI string into a ProfileItem object.
+ *
+ * @param str the standard Vmess URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parseVmessStd(str: String): ProfileItem? {
+ val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
+ val config = ProfileItem.create(EConfigType.VMESS)
+
+ val uri = URI(Utils.fixIllegalUrl(str))
+ if (uri.rawQuery.isNullOrEmpty()) return null
+ val queryParam = getQueryParam(uri)
+
+ config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
+ config.server = uri.idnHost
+ config.serverPort = uri.port.toString()
+ config.password = uri.userInfo
+ config.method = AppConfig.DEFAULT_SECURITY
+
+ getItemFormQuery(config, queryParam, allowInsecure)
+
+ return config
+ }
+
+ /**
+ * Converts a ProfileItem object to an OutboundBean object.
+ *
+ * @param profileItem the ProfileItem object to convert
+ * @return the converted OutboundBean object, or null if conversion fails
+ */
+ fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+ val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
+
+ outboundBean?.settings?.vnext?.first()?.let { vnext ->
+ vnext.address = getServerAddress(profileItem)
+ vnext.port = profileItem.serverPort.orEmpty().toInt()
+ vnext.users[0].id = profileItem.password.orEmpty()
+ vnext.users[0].security = profileItem.method
+ }
+
+ val sni = outboundBean?.streamSettings?.let {
+ V2rayConfigManager.populateTransportSettings(it, profileItem)
+ }
+
+ outboundBean?.streamSettings?.let {
+ V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
+ }
+
+ return outboundBean
+ }
+
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt
new file mode 100644
index 00000000..8f1cec84
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/WireguardFmt.kt
@@ -0,0 +1,149 @@
+package com.v2ray.ang.fmt
+
+import com.v2ray.ang.AppConfig
+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.V2rayConfig.OutboundBean
+import com.v2ray.ang.extension.idnHost
+import com.v2ray.ang.extension.removeWhiteSpace
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object WireguardFmt : FmtBase() {
+ /**
+ * Parses a URI string into a ProfileItem object.
+ *
+ * @param str the URI string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parse(str: String): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.WIREGUARD)
+
+ val uri = URI(Utils.fixIllegalUrl(str))
+ if (uri.rawQuery.isNullOrEmpty()) return null
+ val queryParam = getQueryParam(uri)
+
+ config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
+ config.server = uri.idnHost
+ config.serverPort = uri.port.toString()
+
+ config.secretKey = uri.userInfo.orEmpty()
+ config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
+ config.publicKey = queryParam["publickey"].orEmpty()
+ config.preSharedKey = queryParam["presharedkey"]?.takeIf { it.isNotEmpty() }
+ config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
+ config.reserved = queryParam["reserved"] ?: "0,0,0"
+
+ return config
+ }
+
+ /**
+ * Parses a Wireguard configuration file string into a ProfileItem object.
+ *
+ * @param str the Wireguard configuration file string to parse
+ * @return the parsed ProfileItem object, or null if parsing fails
+ */
+ fun parseWireguardConfFile(str: String): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.WIREGUARD)
+
+ val interfaceParams: MutableMap = mutableMapOf()
+ val peerParams: MutableMap = mutableMapOf()
+
+ var currentSection: String? = null
+
+ str.lines().forEach { line ->
+ val trimmedLine = line.trim()
+
+ if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
+ return@forEach
+ }
+
+ when {
+ trimmedLine.startsWith("[Interface]", ignoreCase = true) -> currentSection = "Interface"
+ trimmedLine.startsWith("[Peer]", ignoreCase = true) -> currentSection = "Peer"
+ else -> {
+ if (currentSection != null) {
+ val parts = trimmedLine.split("=", limit = 2).map { it.trim() }
+ if (parts.size == 2) {
+ val key = parts[0].lowercase()
+ val value = parts[1]
+ when (currentSection) {
+ "Interface" -> interfaceParams[key] = value
+ "Peer" -> peerParams[key] = value
+ }
+ }
+ }
+ }
+ }
+ }
+
+ config.secretKey = interfaceParams["privatekey"].orEmpty()
+ config.remarks = System.currentTimeMillis().toString()
+ config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
+ config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
+ config.publicKey = peerParams["publickey"].orEmpty()
+ config.preSharedKey = peerParams["presharedkey"]?.takeIf { it.isNotEmpty() }
+ val endpoint = peerParams["endpoint"].orEmpty()
+ val endpointParts = endpoint.split(":", limit = 2)
+ if (endpointParts.size == 2) {
+ config.server = endpointParts[0]
+ config.serverPort = endpointParts[1]
+ } else {
+ config.server = endpoint
+ config.serverPort = ""
+ }
+ config.reserved = peerParams["reserved"] ?: "0,0,0"
+
+ return config
+ }
+
+ /**
+ * Converts a ProfileItem object to an OutboundBean object.
+ *
+ * @param profileItem the ProfileItem object to convert
+ * @return the converted OutboundBean object, or null if conversion fails
+ */
+ fun toOutbound(profileItem: ProfileItem): OutboundBean? {
+ val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.WIREGUARD)
+
+ outboundBean?.settings?.let { wireguard ->
+ wireguard.secretKey = profileItem.secretKey
+ wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
+ wireguard.peers?.firstOrNull()?.let { peer ->
+ peer.publicKey = profileItem.publicKey.orEmpty()
+ peer.preSharedKey = profileItem.preSharedKey?.takeIf { it.isNotEmpty() }
+ peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
+ }
+ wireguard.mtu = profileItem.mtu
+ wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
+ }
+
+ return outboundBean
+ }
+
+ /**
+ * Converts a ProfileItem object to a URI string.
+ *
+ * @param config the ProfileItem object to convert
+ * @return the converted URI string
+ */
+ fun toUri(config: ProfileItem): String {
+ val dicQuery = HashMap()
+
+ dicQuery["publickey"] = config.publicKey.orEmpty()
+ if (config.reserved != null) {
+ dicQuery["reserved"] = config.reserved.removeWhiteSpace().orEmpty()
+ }
+ dicQuery["address"] = config.localAddress.removeWhiteSpace().orEmpty()
+ if (config.mtu != null) {
+ dicQuery["mtu"] = config.mtu.toString()
+ }
+ if (config.preSharedKey != null) {
+ dicQuery["presharedkey"] = config.preSharedKey.removeWhiteSpace().orEmpty()
+ }
+
+ return toUri(config, config.secretKey, dicQuery)
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt
new file mode 100644
index 00000000..d24ae0c2
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/AngConfigManager.kt
@@ -0,0 +1,493 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.text.TextUtils
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.HY2
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.SubscriptionItem
+import com.v2ray.ang.fmt.CustomFmt
+import com.v2ray.ang.fmt.Hysteria2Fmt
+import com.v2ray.ang.fmt.ShadowsocksFmt
+import com.v2ray.ang.fmt.SocksFmt
+import com.v2ray.ang.fmt.TrojanFmt
+import com.v2ray.ang.fmt.VlessFmt
+import com.v2ray.ang.fmt.VmessFmt
+import com.v2ray.ang.fmt.WireguardFmt
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.QRCodeDecoder
+import com.v2ray.ang.util.Utils
+import java.net.URI
+
+object AngConfigManager {
+
+
+ /**
+ * Shares the configuration to the clipboard.
+ *
+ * @param context The context.
+ * @param guid The GUID of the configuration.
+ * @return The result code.
+ */
+ fun share2Clipboard(context: Context, guid: String): Int {
+ try {
+ val conf = shareConfig(guid)
+ if (TextUtils.isEmpty(conf)) {
+ return -1
+ }
+
+ Utils.setClipboard(context, conf)
+
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to share config to clipboard", e)
+ return -1
+ }
+ return 0
+ }
+
+ /**
+ * Shares non-custom configurations to the clipboard.
+ *
+ * @param context The context.
+ * @param serverList The list of server GUIDs.
+ * @return The number of configurations shared.
+ */
+ fun shareNonCustomConfigsToClipboard(context: Context, serverList: List): Int {
+ try {
+ val sb = StringBuilder()
+ for (guid in serverList) {
+ val url = shareConfig(guid)
+ if (TextUtils.isEmpty(url)) {
+ continue
+ }
+ sb.append(url)
+ sb.appendLine()
+ }
+ if (sb.count() > 0) {
+ Utils.setClipboard(context, sb.toString())
+ }
+ return sb.lines().count()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
+ return -1
+ }
+ }
+
+ /**
+ * Shares the configuration as a QR code.
+ *
+ * @param guid The GUID of the configuration.
+ * @return The QR code bitmap.
+ */
+ fun share2QRCode(guid: String): Bitmap? {
+ try {
+ val conf = shareConfig(guid)
+ if (TextUtils.isEmpty(conf)) {
+ return null
+ }
+ return QRCodeDecoder.createQRCode(conf)
+
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to share config as QR code", e)
+ return null
+ }
+ }
+
+ /**
+ * Shares the full content of the configuration to the clipboard.
+ *
+ * @param context The context.
+ * @param guid The GUID of the configuration.
+ * @return The result code.
+ */
+ fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
+ try {
+ if (guid == null) return -1
+ val result = V2rayConfigManager.getV2rayConfig(context, guid)
+ 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)
+ } else {
+ return -1
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
+ return -1
+ }
+ return 0
+ }
+
+ /**
+ * Shares the configuration.
+ *
+ * @param guid The GUID of the configuration.
+ * @return The configuration string.
+ */
+ private fun shareConfig(guid: String): String {
+ try {
+ val config = MmkvManager.decodeServerConfig(guid) ?: return ""
+
+ return config.configType.protocolScheme + when (config.configType) {
+ EConfigType.VMESS -> VmessFmt.toUri(config)
+ EConfigType.CUSTOM -> ""
+ EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
+ EConfigType.SOCKS -> SocksFmt.toUri(config)
+ EConfigType.HTTP -> ""
+ EConfigType.VLESS -> VlessFmt.toUri(config)
+ EConfigType.TROJAN -> TrojanFmt.toUri(config)
+ EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
+ EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
+ return ""
+ }
+ }
+
+ /**
+ * Imports a batch of configurations.
+ *
+ * @param server The server string.
+ * @param subid The subscription ID.
+ * @param append Whether to append the configurations.
+ * @return A pair containing the number of configurations and subscriptions imported.
+ */
+ fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair {
+ var count = parseBatchConfig(Utils.decode(server), subid, append)
+ if (count <= 0) {
+ count = parseBatchConfig(server, subid, append)
+ }
+ if (count <= 0) {
+ count = parseCustomConfigServer(server, subid)
+ }
+
+ var countSub = parseBatchSubscription(server)
+ if (countSub <= 0) {
+ countSub = parseBatchSubscription(Utils.decode(server))
+ }
+ if (countSub > 0) {
+ updateConfigViaSubAll()
+ }
+
+ return count to countSub
+ }
+
+ /**
+ * Parses a batch of subscriptions.
+ *
+ * @param servers The servers string.
+ * @return The number of subscriptions parsed.
+ */
+ private fun parseBatchSubscription(servers: String?): Int {
+ try {
+ if (servers == null) {
+ return 0
+ }
+
+ var count = 0
+ servers.lines()
+ .distinct()
+ .forEach { str ->
+ if (Utils.isValidSubUrl(str)) {
+ count += importUrlAsSubscription(str)
+ }
+ }
+ return count
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to parse batch subscription", e)
+ }
+ return 0
+ }
+
+ /**
+ * Parses a batch of configurations.
+ *
+ * @param servers The servers string.
+ * @param subid The subscription ID.
+ * @param append Whether to append the configurations.
+ * @return The number of configurations parsed.
+ */
+ private fun parseBatchConfig(servers: String?, subid: String, append: Boolean): Int {
+ try {
+ if (servers == null) {
+ return 0
+ }
+ val removedSelectedServer =
+ if (!TextUtils.isEmpty(subid) && !append) {
+ MmkvManager.decodeServerConfig(
+ MmkvManager.getSelectServer().orEmpty()
+ )?.let {
+ if (it.subscriptionId == subid) {
+ return@let it
+ }
+ return@let null
+ }
+ } else {
+ null
+ }
+ if (!append) {
+ MmkvManager.removeServerViaSubid(subid)
+ }
+
+ val subItem = MmkvManager.decodeSubscription(subid)
+ var count = 0
+ servers.lines()
+ .distinct()
+ .reversed()
+ .forEach {
+ val resId = parseConfig(it, subid, subItem, removedSelectedServer)
+ if (resId == 0) {
+ count++
+ }
+ }
+ return count
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to parse batch config", e)
+ }
+ return 0
+ }
+
+ /**
+ * Parses a custom configuration server.
+ *
+ * @param server The server string.
+ * @param subid The subscription ID.
+ * @return The number of configurations parsed.
+ */
+ private fun parseCustomConfigServer(server: String?, subid: String): Int {
+ if (server == null) {
+ return 0
+ }
+ if (server.contains("inbounds")
+ && server.contains("outbounds")
+ && server.contains("routing")
+ ) {
+ try {
+ val serverList: Array =
+ JsonUtil.fromJson(server, Array::class.java)
+
+ if (serverList.isNotEmpty()) {
+ var count = 0
+ for (srv in serverList.reversed()) {
+ val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
+ config.subscriptionId = subid
+ val key = MmkvManager.encodeServerConfig("", config)
+ MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
+ count += 1
+ }
+ return count
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
+ }
+
+ try {
+ // For compatibility
+ val config = CustomFmt.parse(server) ?: return 0
+ config.subscriptionId = subid
+ val key = MmkvManager.encodeServerConfig("", config)
+ MmkvManager.encodeServerRaw(key, server)
+ return 1
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
+ }
+ return 0
+ } else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
+ try {
+ val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
+ val key = MmkvManager.encodeServerConfig("", config)
+ MmkvManager.encodeServerRaw(key, server)
+ return 1
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
+ }
+ return 0
+ } else {
+ return 0
+ }
+ }
+
+ /**
+ * Parses the configuration from a QR code or string.
+ *
+ * @param str The configuration string.
+ * @param subid The subscription ID.
+ * @param subItem The subscription item.
+ * @param removedSelectedServer The removed selected server.
+ * @return The result code.
+ */
+ private fun parseConfig(
+ str: String?,
+ subid: String,
+ subItem: SubscriptionItem?,
+ removedSelectedServer: ProfileItem?
+ ): Int {
+ try {
+ if (str == null || TextUtils.isEmpty(str)) {
+ return R.string.toast_none_data
+ }
+
+ val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
+ VmessFmt.parse(str)
+ } else if (str.startsWith(EConfigType.SHADOWSOCKS.protocolScheme)) {
+ ShadowsocksFmt.parse(str)
+ } else if (str.startsWith(EConfigType.SOCKS.protocolScheme)) {
+ SocksFmt.parse(str)
+ } else if (str.startsWith(EConfigType.TROJAN.protocolScheme)) {
+ TrojanFmt.parse(str)
+ } else if (str.startsWith(EConfigType.VLESS.protocolScheme)) {
+ VlessFmt.parse(str)
+ } else if (str.startsWith(EConfigType.WIREGUARD.protocolScheme)) {
+ WireguardFmt.parse(str)
+ } else if (str.startsWith(EConfigType.HYSTERIA2.protocolScheme) || str.startsWith(HY2)) {
+ Hysteria2Fmt.parse(str)
+ } else {
+ null
+ }
+
+ if (config == null) {
+ return R.string.toast_incorrect_protocol
+ }
+ //filter
+ if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
+ val matched = Regex(pattern = subItem.filter ?: "")
+ .containsMatchIn(input = config.remarks)
+ if (!matched) return -1
+ }
+
+ config.subscriptionId = subid
+ val guid = MmkvManager.encodeServerConfig("", config)
+ if (removedSelectedServer != null &&
+ config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
+ ) {
+ MmkvManager.setSelectServer(guid)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to parse config", e)
+ return -1
+ }
+ return 0
+ }
+
+ /**
+ * Updates the configuration via all subscriptions.
+ *
+ * @return The number of configurations updated.
+ */
+ fun updateConfigViaSubAll(): Int {
+ var count = 0
+ try {
+ MmkvManager.decodeSubscriptions().forEach {
+ count += updateConfigViaSub(it)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
+ return 0
+ }
+ return count
+ }
+
+ /**
+ * Updates the configuration via a subscription.
+ *
+ * @param it The subscription item.
+ * @return The number of configurations updated.
+ */
+ fun updateConfigViaSub(it: Pair): Int {
+ try {
+ if (TextUtils.isEmpty(it.first)
+ || TextUtils.isEmpty(it.second.remarks)
+ || TextUtils.isEmpty(it.second.url)
+ ) {
+ return 0
+ }
+ if (!it.second.enabled) {
+ return 0
+ }
+ val url = HttpUtil.toIdnUrl(it.second.url)
+ if (!Utils.isValidUrl(url)) {
+ return 0
+ }
+ if (!it.second.allowInsecureUrl) {
+ if (!Utils.isValidSubUrl(url)) {
+ return 0
+ }
+ }
+ Log.i(AppConfig.TAG, url)
+
+ var configText = try {
+ val httpPort = SettingsManager.getHttpPort()
+ HttpUtil.getUrlContentWithUserAgent(url, 15000, httpPort)
+ } catch (e: Exception) {
+ Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
+ ""
+ }
+ if (configText.isEmpty()) {
+ configText = try {
+ HttpUtil.getUrlContentWithUserAgent(url)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
+ ""
+ }
+ }
+ if (configText.isEmpty()) {
+ return 0
+ }
+ return parseConfigViaSub(configText, it.first, false)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
+ return 0
+ }
+ }
+
+ /**
+ * Parses the configuration via a subscription.
+ *
+ * @param server The server string.
+ * @param subid The subscription ID.
+ * @param append Whether to append the configurations.
+ * @return The number of configurations parsed.
+ */
+ private fun parseConfigViaSub(server: String?, subid: String, append: Boolean): Int {
+ var count = parseBatchConfig(Utils.decode(server), subid, append)
+ if (count <= 0) {
+ count = parseBatchConfig(server, subid, append)
+ }
+ if (count <= 0) {
+ count = parseCustomConfigServer(server, subid)
+ }
+ return count
+ }
+
+ /**
+ * Imports a URL as a subscription.
+ *
+ * @param url The URL.
+ * @return The number of subscriptions imported.
+ */
+ private fun importUrlAsSubscription(url: String): Int {
+ val subscriptions = MmkvManager.decodeSubscriptions()
+ subscriptions.forEach {
+ if (it.second.url == url) {
+ return 0
+ }
+ }
+ val uri = URI(Utils.fixIllegalUrl(url))
+ val subItem = SubscriptionItem()
+ subItem.remarks = uri.fragment ?: "import sub"
+ subItem.url = url
+ MmkvManager.encodeSubscription("", subItem)
+ return 1
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MigrateManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MigrateManager.kt
new file mode 100644
index 00000000..9dd90c76
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MigrateManager.kt
@@ -0,0 +1,242 @@
+package com.v2ray.ang.handler
+
+import android.util.Log
+import com.tencent.mmkv.MMKV
+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.ServerConfig
+import com.v2ray.ang.extension.removeWhiteSpace
+import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
+import com.v2ray.ang.util.JsonUtil
+
+object MigrateManager {
+ private const val ID_SERVER_CONFIG = "SERVER_CONFIG"
+ private val serverStorage by lazy { MMKV.mmkvWithID(ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
+
+ /**
+ * Migrates server configurations to profile items.
+ *
+ * @return True if migration was successful, false otherwise.
+ */
+ fun migrateServerConfig2Profile(): Boolean {
+ if (serverStorage.count().toInt() == 0) {
+ return false
+ }
+ val serverList = serverStorage.allKeys() ?: return false
+ Log.i(AppConfig.TAG, "migrateServerConfig2Profile-" + serverList.count())
+
+ for (guid in serverList) {
+ var configOld = decodeServerConfigOld(guid) ?: continue
+ var config = decodeServerConfig(guid)
+ if (config != null) {
+ serverStorage.remove(guid)
+ continue
+ }
+ config = migrateServerConfig2ProfileSub(configOld) ?: continue
+ config.subscriptionId = configOld.subscriptionId
+
+ MmkvManager.encodeServerConfig(guid, config)
+
+ //check and remove old
+ decodeServerConfig(guid) ?: continue
+ serverStorage.remove(guid)
+ Log.i(AppConfig.TAG, "migrateServerConfig2Profile-" + config.remarks)
+ }
+ Log.i(AppConfig.TAG, "migrateServerConfig2Profile-end")
+ return true
+ }
+
+ /**
+ * Migrates a server configuration to a profile item.
+ *
+ * @param configOld The old server configuration.
+ * @return The profile item.
+ */
+ private fun migrateServerConfig2ProfileSub(configOld: ServerConfig): ProfileItem? {
+ return when (configOld.getProxyOutbound()?.protocol) {
+ EConfigType.VMESS.name.lowercase() -> migrate2ProfileCommon(configOld)
+ EConfigType.VLESS.name.lowercase() -> migrate2ProfileCommon(configOld)
+ EConfigType.TROJAN.name.lowercase() -> migrate2ProfileCommon(configOld)
+ EConfigType.SHADOWSOCKS.name.lowercase() -> migrate2ProfileCommon(configOld)
+
+ EConfigType.SOCKS.name.lowercase() -> migrate2ProfileSocks(configOld)
+ EConfigType.HTTP.name.lowercase() -> migrate2ProfileHttp(configOld)
+ EConfigType.WIREGUARD.name.lowercase() -> migrate2ProfileWireguard(configOld)
+ EConfigType.HYSTERIA2.name.lowercase() -> migrate2ProfileHysteria2(configOld)
+
+ EConfigType.CUSTOM.name.lowercase() -> migrate2ProfileCustom(configOld)
+
+ else -> null
+ }
+ }
+
+ /**
+ * Migrates a common server configuration to a profile item.
+ *
+ * @param configOld The old server configuration.
+ * @return The profile item.
+ */
+ private fun migrate2ProfileCommon(configOld: ServerConfig): ProfileItem? {
+ val config = ProfileItem.create(configOld.configType)
+
+ val outbound = configOld.getProxyOutbound() ?: return null
+ config.remarks = configOld.remarks
+ config.server = outbound.getServerAddress()
+ config.serverPort = outbound.getServerPort().toString()
+ config.method = outbound.getSecurityEncryption()
+ config.password = outbound.getPassword()
+ config.flow = outbound?.settings?.vnext?.first()?.users?.first()?.flow ?: outbound?.settings?.servers?.first()?.flow
+
+ config.network = outbound?.streamSettings?.network ?: NetworkType.TCP.type
+ outbound.getTransportSettingDetails()?.let { transportDetails ->
+ config.headerType = transportDetails[0].orEmpty()
+ config.host = transportDetails[1].orEmpty()
+ config.path = transportDetails[2].orEmpty()
+ }
+
+ config.seed = outbound?.streamSettings?.kcpSettings?.seed
+ config.quicSecurity = outbound?.streamSettings?.quicSettings?.security
+ config.quicKey = outbound?.streamSettings?.quicSettings?.key
+ config.mode = if (outbound?.streamSettings?.grpcSettings?.multiMode == true) "multi" else "gun"
+ config.serviceName = outbound?.streamSettings?.grpcSettings?.serviceName
+ config.authority = outbound?.streamSettings?.grpcSettings?.authority
+
+ config.security = outbound.streamSettings?.security
+ val tlsSettings = outbound?.streamSettings?.realitySettings ?: outbound?.streamSettings?.tlsSettings
+ config.insecure = tlsSettings?.allowInsecure
+ config.sni = tlsSettings?.serverName
+ config.fingerPrint = tlsSettings?.fingerprint
+ config.alpn = tlsSettings?.alpn?.joinToString(",").removeWhiteSpace().toString()
+
+ config.publicKey = tlsSettings?.publicKey
+ config.shortId = tlsSettings?.shortId
+ config.spiderX = tlsSettings?.spiderX
+
+ return config
+ }
+
+ /**
+ * Migrates a SOCKS server configuration to a profile item.
+ *
+ * @param configOld The old server configuration.
+ * @return The profile item.
+ */
+ private fun migrate2ProfileSocks(configOld: ServerConfig): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.SOCKS)
+
+ val outbound = configOld.getProxyOutbound() ?: return null
+ config.remarks = configOld.remarks
+ config.server = outbound.getServerAddress()
+ config.serverPort = outbound.getServerPort().toString()
+ config.username = outbound.settings?.servers?.first()?.users?.first()?.user
+ config.password = outbound.getPassword()
+
+ return config
+ }
+
+ /**
+ * Migrates an HTTP server configuration to a profile item.
+ *
+ * @param configOld The old server configuration.
+ * @return The profile item.
+ */
+ private fun migrate2ProfileHttp(configOld: ServerConfig): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.HTTP)
+
+ val outbound = configOld.getProxyOutbound() ?: return null
+ config.remarks = configOld.remarks
+ config.server = outbound.getServerAddress()
+ config.serverPort = outbound.getServerPort().toString()
+ config.username = outbound.settings?.servers?.first()?.users?.first()?.user
+ config.password = outbound.getPassword()
+
+ return config
+ }
+
+ /**
+ * Migrates a WireGuard server configuration to a profile item.
+ *
+ * @param configOld The old server configuration.
+ * @return The profile item.
+ */
+ private fun migrate2ProfileWireguard(configOld: ServerConfig): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.WIREGUARD)
+
+ val outbound = configOld.getProxyOutbound() ?: return null
+ config.remarks = configOld.remarks
+ config.server = outbound.getServerAddress()
+ config.serverPort = outbound.getServerPort().toString()
+
+ outbound.settings?.let { wireguard ->
+ config.secretKey = wireguard.secretKey
+ config.localAddress = (wireguard.address as List<*>).joinToString(",").removeWhiteSpace().toString()
+ config.publicKey = wireguard.peers?.getOrNull(0)?.publicKey
+ config.mtu = wireguard.mtu
+ config.reserved = wireguard.reserved?.joinToString(",").removeWhiteSpace().toString()
+ }
+ return config
+ }
+
+ /**
+ * Migrates a Hysteria2 server configuration to a profile item.
+ *
+ * @param configOld The old server configuration.
+ * @return The profile item.
+ */
+ private fun migrate2ProfileHysteria2(configOld: ServerConfig): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.HYSTERIA2)
+
+ val outbound = configOld.getProxyOutbound() ?: return null
+ config.remarks = configOld.remarks
+ config.server = outbound.getServerAddress()
+ config.serverPort = outbound.getServerPort().toString()
+ config.password = outbound.getPassword()
+
+ config.security = AppConfig.TLS
+ outbound.streamSettings?.tlsSettings?.let { tlsSetting ->
+ config.insecure = tlsSetting.allowInsecure
+ config.sni = tlsSetting.serverName
+ config.alpn = tlsSetting.alpn?.joinToString(",").removeWhiteSpace().orEmpty()
+
+ }
+ config.obfsPassword = outbound.settings?.obfsPassword
+
+ return config
+ }
+
+ /**
+ * Migrates a custom server configuration to a profile item.
+ *
+ * @param configOld The old server configuration.
+ * @return The profile item.
+ */
+ private fun migrate2ProfileCustom(configOld: ServerConfig): ProfileItem? {
+ val config = ProfileItem.create(EConfigType.CUSTOM)
+
+ val outbound = configOld.getProxyOutbound() ?: return null
+ config.remarks = configOld.remarks
+ config.server = outbound.getServerAddress()
+ config.serverPort = outbound.getServerPort().toString()
+
+ return config
+ }
+
+ /**
+ * Decodes the old server configuration.
+ *
+ * @param guid The server GUID.
+ * @return The old server configuration.
+ */
+ private fun decodeServerConfigOld(guid: String): ServerConfig? {
+ if (guid.isBlank()) {
+ return null
+ }
+ val json = serverStorage.decodeString(guid)
+ if (json.isNullOrBlank()) {
+ return null
+ }
+ return JsonUtil.fromJson(json, ServerConfig::class.java)
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt
new file mode 100644
index 00000000..9b589dd0
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt
@@ -0,0 +1,588 @@
+package com.v2ray.ang.handler
+
+import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
+import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
+import com.v2ray.ang.dto.AssetUrlItem
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.RulesetItem
+import com.v2ray.ang.dto.ServerAffiliationInfo
+import com.v2ray.ang.dto.SubscriptionItem
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+
+object MmkvManager {
+
+ //region private
+
+ //private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
+ private const val ID_MAIN = "MAIN"
+ private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
+ private const val ID_SERVER_RAW = "SERVER_RAW"
+ private const val ID_SERVER_AFF = "SERVER_AFF"
+ private const val ID_SUB = "SUB"
+ private const val ID_ASSET = "ASSET"
+ private const val ID_SETTING = "SETTING"
+ private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
+ private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
+ private const val KEY_SUB_IDS = "SUB_IDS"
+
+ //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 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 serverAffStorage by lazy { MMKV.mmkvWithID(ID_SERVER_AFF, MMKV.MULTI_PROCESS_MODE) }
+ private val subStorage by lazy { MMKV.mmkvWithID(ID_SUB, MMKV.MULTI_PROCESS_MODE) }
+ private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
+ private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
+
+ //endregion
+
+ //region Server
+
+ /**
+ * Gets the selected server GUID.
+ *
+ * @return The selected server GUID.
+ */
+ fun getSelectServer(): String? {
+ return mainStorage.decodeString(KEY_SELECTED_SERVER)
+ }
+
+ /**
+ * Sets the selected server GUID.
+ *
+ * @param guid The server GUID.
+ */
+ fun setSelectServer(guid: String) {
+ mainStorage.encode(KEY_SELECTED_SERVER, guid)
+ }
+
+ /**
+ * Encodes the server list.
+ *
+ * @param serverList The list of server GUIDs.
+ */
+ fun encodeServerList(serverList: MutableList) {
+ mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
+ }
+
+ /**
+ * Decodes the server list.
+ *
+ * @return The list of server GUIDs.
+ */
+ fun decodeServerList(): MutableList {
+ val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
+ return if (json.isNullOrBlank()) {
+ mutableListOf()
+ } else {
+ JsonUtil.fromJson(json, Array::class.java).toMutableList()
+ }
+ }
+
+ /**
+ * Decodes the server configuration.
+ *
+ * @param guid The server GUID.
+ * @return The server configuration.
+ */
+ fun decodeServerConfig(guid: String): ProfileItem? {
+ if (guid.isBlank()) {
+ return null
+ }
+ val json = profileFullStorage.decodeString(guid)
+ if (json.isNullOrBlank()) {
+ return null
+ }
+ 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.
+ *
+ * @param guid The server GUID.
+ * @param config The server configuration.
+ * @return The server GUID.
+ */
+ fun encodeServerConfig(guid: String, config: ProfileItem): String {
+ val key = guid.ifBlank { Utils.getUuid() }
+ profileFullStorage.encode(key, JsonUtil.toJson(config))
+ val serverList = decodeServerList()
+ if (!serverList.contains(key)) {
+ serverList.add(0, key)
+ encodeServerList(serverList)
+ if (getSelectServer().isNullOrBlank()) {
+ 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
+ }
+
+ /**
+ * Removes the server configuration.
+ *
+ * @param guid The server GUID.
+ */
+ fun removeServer(guid: String) {
+ if (guid.isBlank()) {
+ return
+ }
+ if (getSelectServer() == guid) {
+ mainStorage.remove(KEY_SELECTED_SERVER)
+ }
+ val serverList = decodeServerList()
+ serverList.remove(guid)
+ encodeServerList(serverList)
+ profileFullStorage.remove(guid)
+ //profileStorage.remove(guid)
+ serverAffStorage.remove(guid)
+ }
+
+ /**
+ * Removes the server configurations via subscription ID.
+ *
+ * @param subid The subscription ID.
+ */
+ fun removeServerViaSubid(subid: String) {
+ if (subid.isBlank()) {
+ return
+ }
+ profileFullStorage.allKeys()?.forEach { key ->
+ decodeServerConfig(key)?.let { config ->
+ if (config.subscriptionId == subid) {
+ removeServer(key)
+ }
+ }
+ }
+ }
+
+ /**
+ * Decodes the server affiliation information.
+ *
+ * @param guid The server GUID.
+ * @return The server affiliation information.
+ */
+ fun decodeServerAffiliationInfo(guid: String): ServerAffiliationInfo? {
+ if (guid.isBlank()) {
+ return null
+ }
+ val json = serverAffStorage.decodeString(guid)
+ if (json.isNullOrBlank()) {
+ return null
+ }
+ return JsonUtil.fromJson(json, ServerAffiliationInfo::class.java)
+ }
+
+ /**
+ * Encodes the server test delay in milliseconds.
+ *
+ * @param guid The server GUID.
+ * @param testResult The test delay in milliseconds.
+ */
+ fun encodeServerTestDelayMillis(guid: String, testResult: Long) {
+ if (guid.isBlank()) {
+ return
+ }
+ val aff = decodeServerAffiliationInfo(guid) ?: ServerAffiliationInfo()
+ aff.testDelayMillis = testResult
+ serverAffStorage.encode(guid, JsonUtil.toJson(aff))
+ }
+
+ /**
+ * Clears all test delay results.
+ *
+ * @param keys The list of server GUIDs.
+ */
+ fun clearAllTestDelayResults(keys: List?) {
+ keys?.forEach { key ->
+ decodeServerAffiliationInfo(key)?.let { aff ->
+ aff.testDelayMillis = 0
+ serverAffStorage.encode(key, JsonUtil.toJson(aff))
+ }
+ }
+ }
+
+ /**
+ * Removes all server configurations.
+ *
+ * @return The number of server configurations removed.
+ */
+ fun removeAllServer(): Int {
+ val count = profileFullStorage.allKeys()?.count() ?: 0
+ mainStorage.clearAll()
+ profileFullStorage.clearAll()
+ //profileStorage.clearAll()
+ serverAffStorage.clearAll()
+ return count
+ }
+
+ /**
+ * Removes invalid server configurations.
+ *
+ * @param guid The server GUID.
+ * @return The number of server configurations removed.
+ */
+ fun removeInvalidServer(guid: String): Int {
+ var count = 0
+ if (guid.isNotEmpty()) {
+ decodeServerAffiliationInfo(guid)?.let { aff ->
+ if (aff.testDelayMillis < 0L) {
+ removeServer(guid)
+ count++
+ }
+ }
+ } else {
+ serverAffStorage.allKeys()?.forEach { key ->
+ decodeServerAffiliationInfo(key)?.let { aff ->
+ if (aff.testDelayMillis < 0L) {
+ removeServer(key)
+ count++
+ }
+ }
+ }
+ }
+ return count
+ }
+
+ /**
+ * Encodes the raw server configuration.
+ *
+ * @param guid The server GUID.
+ * @param config The raw server configuration.
+ */
+ fun encodeServerRaw(guid: String, config: String) {
+ serverRawStorage.encode(guid, config)
+ }
+
+ /**
+ * Decodes the raw server configuration.
+ *
+ * @param guid The server GUID.
+ * @return The raw server configuration.
+ */
+ fun decodeServerRaw(guid: String): String? {
+ return serverRawStorage.decodeString(guid)
+ }
+
+ //endregion
+
+ //region Subscriptions
+
+ /**
+ * Initializes the subscription list.
+ */
+ private fun initSubsList() {
+ val subsList = decodeSubsList()
+ if (subsList.isNotEmpty()) {
+ return
+ }
+ subStorage.allKeys()?.forEach { key ->
+ subsList.add(key)
+ }
+ encodeSubsList(subsList)
+ }
+
+ /**
+ * Decodes the subscriptions.
+ *
+ * @return The list of subscriptions.
+ */
+ fun decodeSubscriptions(): List> {
+ initSubsList()
+
+ val subscriptions = mutableListOf>()
+ decodeSubsList().forEach { key ->
+ val json = subStorage.decodeString(key)
+ if (!json.isNullOrBlank()) {
+ subscriptions.add(Pair(key, JsonUtil.fromJson(json, SubscriptionItem::class.java)))
+ }
+ }
+ return subscriptions
+ }
+
+ /**
+ * Removes the subscription.
+ *
+ * @param subid The subscription ID.
+ */
+ fun removeSubscription(subid: String) {
+ subStorage.remove(subid)
+ val subsList = decodeSubsList()
+ subsList.remove(subid)
+ encodeSubsList(subsList)
+
+ removeServerViaSubid(subid)
+ }
+
+ /**
+ * Encodes the subscription.
+ *
+ * @param guid The subscription GUID.
+ * @param subItem The subscription item.
+ */
+ fun encodeSubscription(guid: String, subItem: SubscriptionItem) {
+ val key = guid.ifBlank { Utils.getUuid() }
+ subStorage.encode(key, JsonUtil.toJson(subItem))
+
+ val subsList = decodeSubsList()
+ if (!subsList.contains(key)) {
+ subsList.add(key)
+ encodeSubsList(subsList)
+ }
+ }
+
+ /**
+ * Decodes the subscription.
+ *
+ * @param subscriptionId The subscription ID.
+ * @return The subscription item.
+ */
+ fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
+ val json = subStorage.decodeString(subscriptionId) ?: return null
+ return JsonUtil.fromJson(json, SubscriptionItem::class.java)
+ }
+
+ /**
+ * Encodes the subscription list.
+ *
+ * @param subsList The list of subscription IDs.
+ */
+ fun encodeSubsList(subsList: MutableList) {
+ mainStorage.encode(KEY_SUB_IDS, JsonUtil.toJson(subsList))
+ }
+
+ /**
+ * Decodes the subscription list.
+ *
+ * @return The list of subscription IDs.
+ */
+ fun decodeSubsList(): MutableList {
+ val json = mainStorage.decodeString(KEY_SUB_IDS)
+ return if (json.isNullOrBlank()) {
+ mutableListOf()
+ } else {
+ JsonUtil.fromJson(json, Array::class.java).toMutableList()
+ }
+ }
+
+ //endregion
+
+ //region Asset
+
+ /**
+ * Decodes the asset URLs.
+ *
+ * @return The list of asset URLs.
+ */
+ fun decodeAssetUrls(): List> {
+ val assetUrlItems = mutableListOf>()
+ assetStorage.allKeys()?.forEach { key ->
+ val json = assetStorage.decodeString(key)
+ if (!json.isNullOrBlank()) {
+ assetUrlItems.add(Pair(key, JsonUtil.fromJson(json, AssetUrlItem::class.java)))
+ }
+ }
+ return assetUrlItems.sortedBy { (_, value) -> value.addedTime }
+ }
+
+ /**
+ * Removes the asset URL.
+ *
+ * @param assetid The asset ID.
+ */
+ fun removeAssetUrl(assetid: String) {
+ assetStorage.remove(assetid)
+ }
+
+ /**
+ * Encodes the asset.
+ *
+ * @param assetid The asset ID.
+ * @param assetItem The asset item.
+ */
+ fun encodeAsset(assetid: String, assetItem: AssetUrlItem) {
+ val key = assetid.ifBlank { Utils.getUuid() }
+ assetStorage.encode(key, JsonUtil.toJson(assetItem))
+ }
+
+ /**
+ * Decodes the asset.
+ *
+ * @param assetid The asset ID.
+ * @return The asset item.
+ */
+ fun decodeAsset(assetid: String): AssetUrlItem? {
+ val json = assetStorage.decodeString(assetid) ?: return null
+ return JsonUtil.fromJson(json, AssetUrlItem::class.java)
+ }
+
+ //endregion
+
+ //region Routing
+
+ /**
+ * Decodes the routing rulesets.
+ *
+ * @return The list of routing rulesets.
+ */
+ fun decodeRoutingRulesets(): MutableList? {
+ val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
+ if (ruleset.isNullOrEmpty()) return null
+ return JsonUtil.fromJson(ruleset, Array::class.java).toMutableList()
+ }
+
+ /**
+ * Encodes the routing rulesets.
+ *
+ * @param rulesetList The list of routing rulesets.
+ */
+ fun encodeRoutingRulesets(rulesetList: MutableList?) {
+ if (rulesetList.isNullOrEmpty())
+ encodeSettings(PREF_ROUTING_RULESET, "")
+ else
+ encodeSettings(PREF_ROUTING_RULESET, JsonUtil.toJson(rulesetList))
+ }
+
+ //endregion
+
+ /**
+ * Encodes the settings.
+ *
+ * @param key The settings key.
+ * @param value The settings value.
+ * @return Whether the encoding was successful.
+ */
+ fun encodeSettings(key: String, value: String?): Boolean {
+ return settingsStorage.encode(key, value)
+ }
+
+ /**
+ * Encodes the settings.
+ *
+ * @param key The settings key.
+ * @param value The settings value.
+ * @return Whether the encoding was successful.
+ */
+ fun encodeSettings(key: String, value: Int): Boolean {
+ return settingsStorage.encode(key, value)
+ }
+
+ /**
+ * Encodes the settings.
+ *
+ * @param key The settings key.
+ * @param value The settings value.
+ * @return Whether the encoding was successful.
+ */
+ fun encodeSettings(key: String, value: Boolean): Boolean {
+ return settingsStorage.encode(key, value)
+ }
+
+ /**
+ * Encodes the settings.
+ *
+ * @param key The settings key.
+ * @param value The settings value.
+ * @return Whether the encoding was successful.
+ */
+ fun encodeSettings(key: String, value: MutableSet): Boolean {
+ return settingsStorage.encode(key, value)
+ }
+
+ /**
+ * Decodes the settings string.
+ *
+ * @param key The settings key.
+ * @return The settings value.
+ */
+ fun decodeSettingsString(key: String): String? {
+ return settingsStorage.decodeString(key)
+ }
+
+ /**
+ * Decodes the settings string.
+ *
+ * @param key The settings key.
+ * @param defaultValue The default value.
+ * @return The settings value.
+ */
+ fun decodeSettingsString(key: String, defaultValue: String?): String? {
+ return settingsStorage.decodeString(key, defaultValue)
+ }
+
+ /**
+ * Decodes the settings boolean.
+ *
+ * @param key The settings key.
+ * @return The settings value.
+ */
+ fun decodeSettingsBool(key: String): Boolean {
+ return settingsStorage.decodeBool(key, false)
+ }
+
+ /**
+ * Decodes the settings boolean.
+ *
+ * @param key The settings key.
+ * @param defaultValue The default value.
+ * @return The settings value.
+ */
+ fun decodeSettingsBool(key: String, defaultValue: Boolean): Boolean {
+ return settingsStorage.decodeBool(key, defaultValue)
+ }
+
+ /**
+ * Decodes the settings string set.
+ *
+ * @param key The settings key.
+ * @return The settings value.
+ */
+ fun decodeSettingsStringSet(key: String): MutableSet? {
+ return settingsStorage.decodeStringSet(key)
+ }
+
+ //endregion
+
+ //region Others
+
+ /**
+ * Encodes the start on boot setting.
+ *
+ * @param startOnBoot Whether to start on boot.
+ */
+ fun encodeStartOnBoot(startOnBoot: Boolean) {
+ encodeSettings(PREF_IS_BOOTED, startOnBoot)
+ }
+
+ /**
+ * Decodes the start on boot setting.
+ *
+ * @return Whether to start on boot.
+ */
+ fun decodeStartOnBoot(): Boolean {
+ return decodeSettingsBool(PREF_IS_BOOTED, false)
+ }
+
+ //endregion
+
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt
new file mode 100644
index 00000000..b2e23f7f
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt
@@ -0,0 +1,373 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.content.res.AssetManager
+import android.text.TextUtils
+import android.util.Log
+import androidx.appcompat.app.AppCompatDelegate
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
+import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
+import com.v2ray.ang.AppConfig.TAG_DIRECT
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.Language
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.RoutingType
+import com.v2ray.ang.dto.RulesetItem
+import com.v2ray.ang.dto.V2rayConfig
+import com.v2ray.ang.dto.VpnInterfaceAddressConfig
+import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
+import com.v2ray.ang.handler.MmkvManager.decodeServerList
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+import java.io.File
+import java.io.FileOutputStream
+import java.util.Collections
+import java.util.Locale
+
+object SettingsManager {
+
+ /**
+ * Initialize routing rulesets.
+ * @param context The application context.
+ */
+ fun initRoutingRulesets(context: Context) {
+ val exist = MmkvManager.decodeRoutingRulesets()
+ if (exist.isNullOrEmpty()) {
+ val rulesetList = getPresetRoutingRulesets(context)
+ MmkvManager.encodeRoutingRulesets(rulesetList)
+ }
+ }
+
+ /**
+ * Get preset routing rulesets.
+ * @param context The application context.
+ * @param index The index of the routing type.
+ * @return A mutable list of RulesetItem.
+ */
+ private fun getPresetRoutingRulesets(context: Context, index: Int = 0): MutableList? {
+ val fileName = RoutingType.fromIndex(index).fileName
+ val assets = Utils.readTextFromAssets(context, fileName)
+ if (TextUtils.isEmpty(assets)) {
+ return null
+ }
+
+ return JsonUtil.fromJson(assets, Array::class.java).toMutableList()
+ }
+
+ /**
+ * Reset routing rulesets from presets.
+ * @param context The application context.
+ * @param index The index of the routing type.
+ */
+ fun resetRoutingRulesetsFromPresets(context: Context, index: Int) {
+ val rulesetList = getPresetRoutingRulesets(context, index) ?: return
+ resetRoutingRulesetsCommon(rulesetList)
+ }
+
+ /**
+ * Reset routing rulesets.
+ * @param content The content of the rulesets.
+ * @return True if successful, false otherwise.
+ */
+ fun resetRoutingRulesets(content: String?): Boolean {
+ if (content.isNullOrEmpty()) {
+ return false
+ }
+
+ try {
+ val rulesetList = JsonUtil.fromJson(content, Array::class.java).toMutableList()
+ if (rulesetList.isNullOrEmpty()) {
+ return false
+ }
+
+ resetRoutingRulesetsCommon(rulesetList)
+ return true
+ } catch (e: Exception) {
+ Log.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
+ return false
+ }
+ }
+
+ /**
+ * Common method to reset routing rulesets.
+ * @param rulesetList The list of rulesets.
+ */
+ private fun resetRoutingRulesetsCommon(rulesetList: MutableList) {
+ val rulesetNew: MutableList = mutableListOf()
+ MmkvManager.decodeRoutingRulesets()?.forEach { key ->
+ if (key.locked == true) {
+ rulesetNew.add(key)
+ }
+ }
+
+ rulesetNew.addAll(rulesetList)
+ MmkvManager.encodeRoutingRulesets(rulesetNew)
+ }
+
+ /**
+ * Get a routing ruleset by index.
+ * @param index The index of the ruleset.
+ * @return The RulesetItem.
+ */
+ fun getRoutingRuleset(index: Int): RulesetItem? {
+ if (index < 0) return null
+
+ val rulesetList = MmkvManager.decodeRoutingRulesets()
+ if (rulesetList.isNullOrEmpty()) return null
+
+ return rulesetList[index]
+ }
+
+ /**
+ * Save a routing ruleset.
+ * @param index The index of the ruleset.
+ * @param ruleset The RulesetItem to save.
+ */
+ fun saveRoutingRuleset(index: Int, ruleset: RulesetItem?) {
+ if (ruleset == null) return
+
+ var rulesetList = MmkvManager.decodeRoutingRulesets()
+ if (rulesetList.isNullOrEmpty()) {
+ rulesetList = mutableListOf()
+ }
+
+ if (index < 0 || index >= rulesetList.count()) {
+ rulesetList.add(0, ruleset)
+ } else {
+ rulesetList[index] = ruleset
+ }
+ MmkvManager.encodeRoutingRulesets(rulesetList)
+ }
+
+ /**
+ * Remove a routing ruleset by index.
+ * @param index The index of the ruleset.
+ */
+ fun removeRoutingRuleset(index: Int) {
+ if (index < 0) return
+
+ val rulesetList = MmkvManager.decodeRoutingRulesets()
+ if (rulesetList.isNullOrEmpty()) return
+
+ rulesetList.removeAt(index)
+ MmkvManager.encodeRoutingRulesets(rulesetList)
+ }
+
+ /**
+ * Check if routing rulesets bypass LAN.
+ * @return True if bypassing LAN, false otherwise.
+ */
+ fun routingRulesetsBypassLan(): Boolean {
+ val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
+ if (vpnBypassLan == "1") {
+ return true
+ } else if (vpnBypassLan == "2") {
+ return false
+ }
+
+ val guid = MmkvManager.getSelectServer() ?: return false
+ val config = decodeServerConfig(guid) ?: return false
+ if (config.configType == EConfigType.CUSTOM) {
+ val raw = MmkvManager.decodeServerRaw(guid) ?: return false
+ val v2rayConfig = JsonUtil.fromJson(raw, V2rayConfig::class.java)
+ val exist = v2rayConfig.routing.rules.filter { it.outboundTag == TAG_DIRECT }.any {
+ it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
+ }
+ return exist == true
+ }
+
+ val rulesetItems = MmkvManager.decodeRoutingRulesets()
+ val exist = rulesetItems?.filter { it.enabled && it.outboundTag == TAG_DIRECT }?.any {
+ it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
+ }
+ return exist == true
+ }
+
+ /**
+ * Swap routing rulesets.
+ * @param fromPosition The position to swap from.
+ * @param toPosition The position to swap to.
+ */
+ fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) {
+ val rulesetList = MmkvManager.decodeRoutingRulesets()
+ if (rulesetList.isNullOrEmpty()) return
+
+ Collections.swap(rulesetList, fromPosition, toPosition)
+ MmkvManager.encodeRoutingRulesets(rulesetList)
+ }
+
+ /**
+ * Swap subscriptions.
+ * @param fromPosition The position to swap from.
+ * @param toPosition The position to swap to.
+ */
+ fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
+ val subsList = MmkvManager.decodeSubsList()
+ if (subsList.isNullOrEmpty()) return
+
+ Collections.swap(subsList, fromPosition, toPosition)
+ MmkvManager.encodeSubsList(subsList)
+ }
+
+ /**
+ * Get server via remarks.
+ * @param remarks The remarks of the server.
+ * @return The ProfileItem.
+ */
+ fun getServerViaRemarks(remarks: String?): ProfileItem? {
+ if (remarks.isNullOrEmpty()) {
+ return null
+ }
+ val serverList = decodeServerList()
+ for (guid in serverList) {
+ val profile = decodeServerConfig(guid)
+ if (profile != null && profile.remarks == remarks) {
+ return profile
+ }
+ }
+ return null
+ }
+
+ /**
+ * Get the SOCKS port.
+ * @return The SOCKS port.
+ */
+ fun getSocksPort(): Int {
+ return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
+ }
+
+ /**
+ * Get the HTTP port.
+ * @return The HTTP port.
+ */
+ fun getHttpPort(): Int {
+ return getSocksPort() + if (Utils.isXray()) 0 else 1
+ }
+
+ /**
+ * Initialize assets.
+ * @param context The application context.
+ * @param assets The AssetManager.
+ */
+ fun initAssets(context: Context, assets: AssetManager) {
+ val extFolder = Utils.userAssetPath(context)
+
+ try {
+ val geo = arrayOf("geosite.dat", "geoip.dat")
+ assets.list("")
+ ?.filter { geo.contains(it) }
+ ?.filter { !File(extFolder, it).exists() }
+ ?.forEach {
+ val target = File(extFolder, it)
+ assets.open(it).use { input ->
+ FileOutputStream(target).use { output ->
+ input.copyTo(output)
+ }
+ }
+ Log.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
+ }
+ } catch (e: Exception) {
+ Log.e(ANG_PACKAGE, "asset copy failed", e)
+ }
+ }
+
+ /**
+ * Get domestic DNS servers from preference.
+ * @return A list of domestic DNS servers.
+ */
+ fun getDomesticDnsServers(): List {
+ val domesticDns =
+ MmkvManager.decodeSettingsString(AppConfig.PREF_DOMESTIC_DNS) ?: AppConfig.DNS_DIRECT
+ val ret = domesticDns.split(",").filter { Utils.isPureIpAddress(it) || Utils.isCoreDNSAddress(it) }
+ if (ret.isEmpty()) {
+ return listOf(AppConfig.DNS_DIRECT)
+ }
+ return ret
+ }
+
+ /**
+ * Get remote DNS servers from preference.
+ * @return A list of remote DNS servers.
+ */
+ fun getRemoteDnsServers(): List {
+ val remoteDns =
+ MmkvManager.decodeSettingsString(AppConfig.PREF_REMOTE_DNS) ?: AppConfig.DNS_PROXY
+ val ret = remoteDns.split(",").filter { Utils.isPureIpAddress(it) || Utils.isCoreDNSAddress(it) }
+ if (ret.isEmpty()) {
+ return listOf(AppConfig.DNS_PROXY)
+ }
+ return ret
+ }
+
+ /**
+ * Get VPN DNS servers from preference.
+ * @return A list of VPN DNS servers.
+ */
+ fun getVpnDnsServers(): List {
+ val vpnDns = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS) ?: AppConfig.DNS_VPN
+ return vpnDns.split(",").filter { Utils.isPureIpAddress(it) }
+ }
+
+ /**
+ * Get delay test URL.
+ * @param second Whether to use the second URL.
+ * @return The delay test URL.
+ */
+ fun getDelayTestUrl(second: Boolean = false): String {
+ return if (second) {
+ AppConfig.DELAY_TEST_URL2
+ } else {
+ MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL)
+ ?: AppConfig.DELAY_TEST_URL
+ }
+ }
+
+ /**
+ * Get the locale.
+ * @return The locale.
+ */
+ fun getLocale(): Locale {
+ val langCode =
+ MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
+ val language = Language.fromCode(langCode)
+
+ return when (language) {
+ Language.AUTO -> Utils.getSysLocale()
+ Language.ENGLISH -> Locale.ENGLISH
+ Language.CHINA -> Locale.CHINA
+ Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
+ Language.VIETNAMESE -> Locale("vi")
+ Language.RUSSIAN -> Locale("ru")
+ Language.PERSIAN -> Locale("fa")
+ Language.ARABIC -> Locale("ar")
+ Language.BANGLA -> Locale("bn")
+ Language.BAKHTIARI -> Locale("bqi", "IR")
+ }
+ }
+
+ /**
+ * Set night mode.
+ */
+ fun setNightMode() {
+ when (MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0")) {
+ "0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ "1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
+ "2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
+ }
+ }
+
+ /**
+ * Retrieves the currently selected VPN interface address configuration.
+ * This method reads the user's preference for VPN interface addressing and returns
+ * the corresponding configuration containing IPv4 and IPv6 addresses.
+ *
+ * @return The selected VpnInterfaceAddressConfig instance, or the default configuration
+ * if no valid selection is found or if the stored index is invalid.
+ */
+ fun getCurrentVpnInterfaceAddressConfig(): VpnInterfaceAddressConfig {
+ val selectedIndex = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")?.toInt()
+ return VpnInterfaceAddressConfig.getConfigByIndex(selectedIndex ?: 0)
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt
new file mode 100644
index 00000000..e547c378
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SpeedtestManager.kt
@@ -0,0 +1,189 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.os.SystemClock
+import android.text.TextUtils
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.IPAPIInfo
+import com.v2ray.ang.extension.responseLength
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.JsonUtil
+import kotlinx.coroutines.isActive
+import libv2ray.Libv2ray
+import java.io.IOException
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.net.UnknownHostException
+import kotlin.coroutines.coroutineContext
+
+object SpeedtestManager {
+
+ private val tcpTestingSockets = ArrayList()
+
+ /**
+ * Measures the TCP connection time to a given URL and port.
+ *
+ * @param url The URL to connect to.
+ * @param port The port to connect to.
+ * @return The connection time in milliseconds, or -1 if the connection failed.
+ */
+ suspend fun tcping(url: String, port: Int): Long {
+ var time = -1L
+ for (k in 0 until 2) {
+ val one = socketConnectTime(url, port)
+ if (!coroutineContext.isActive) {
+ break
+ }
+ if (one != -1L && (time == -1L || one < time)) {
+ time = one
+ }
+ }
+ return time
+ }
+
+ /**
+ * Measures the real ping time using the V2Ray library.
+ *
+ * @param config The configuration string for the V2Ray library.
+ * @return The ping time in milliseconds, or -1 if the ping failed.
+ */
+ fun realPing(config: String): Long {
+ return try {
+ Libv2ray.measureOutboundDelay(config, SettingsManager.getDelayTestUrl())
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
+ -1L
+ }
+ }
+
+ /**
+ * Measures the ping time to a given URL using the system ping command.
+ *
+ * @param url The URL to ping.
+ * @return The ping time in milliseconds as a string, or "-1ms" if the ping failed.
+ */
+ fun ping(url: String): String {
+ try {
+ val command = "/system/bin/ping -c 3 $url"
+ val process = Runtime.getRuntime().exec(command)
+ val allText = process.inputStream.bufferedReader().use { it.readText() }
+ if (!TextUtils.isEmpty(allText)) {
+ val tempInfo = allText.substring(allText.indexOf("min/avg/max/mdev") + 19)
+ val temps =
+ tempInfo.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ if (temps.count() > 0 && temps[0].length < 10) {
+ return temps[0].toFloat().toInt().toString() + "ms"
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to ping URL: $url", e)
+ }
+ return "-1ms"
+ }
+
+ /**
+ * Measures the time taken to establish a TCP connection to a given URL and port.
+ *
+ * @param url The URL to connect to.
+ * @param port The port to connect to.
+ * @return The connection time in milliseconds, or -1 if the connection failed.
+ */
+ fun socketConnectTime(url: String, port: Int): Long {
+ try {
+ val socket = Socket()
+ synchronized(this) {
+ tcpTestingSockets.add(socket)
+ }
+ val start = System.currentTimeMillis()
+ socket.connect(InetSocketAddress(url, port), 3000)
+ val time = System.currentTimeMillis() - start
+ synchronized(this) {
+ tcpTestingSockets.remove(socket)
+ }
+ socket.close()
+ return time
+ } catch (e: UnknownHostException) {
+ Log.e(AppConfig.TAG, "Unknown host: $url", e)
+ } catch (e: IOException) {
+ Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
+ }
+ return -1
+ }
+
+ /**
+ * Closes all TCP sockets that are currently being tested.
+ */
+ fun closeAllTcpSockets() {
+ synchronized(this) {
+ tcpTestingSockets.forEach {
+ it?.close()
+ }
+ tcpTestingSockets.clear()
+ }
+ }
+
+ /**
+ * Tests the connection to a given URL and port.
+ *
+ * @param context The Context in which the test is running.
+ * @param port The port to connect to.
+ * @return A pair containing the elapsed time in milliseconds and the result message.
+ */
+ fun testConnection(context: Context, port: Int): Pair {
+ var result: String
+ var elapsed = -1L
+
+ val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
+ try {
+ val start = SystemClock.elapsedRealtime()
+ val code = conn.responseCode
+ elapsed = SystemClock.elapsedRealtime() - start
+
+ if (code == 204 || code == 200 && conn.responseLength == 0L) {
+ result = context.getString(R.string.connection_test_available, elapsed)
+ } else {
+ throw IOException(
+ context.getString(
+ R.string.connection_test_error_status_code,
+ code
+ )
+ )
+ }
+ } catch (e: IOException) {
+ Log.e(AppConfig.TAG, "Connection test IOException", e)
+ result = context.getString(R.string.connection_test_error, e.message)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Connection test Exception", e)
+ result = context.getString(R.string.connection_test_error, e.message)
+ } finally {
+ conn.disconnect()
+ }
+
+ return Pair(elapsed, result)
+ }
+
+ fun getRemoteIPInfo(): String? {
+ val httpPort = SettingsManager.getHttpPort()
+ var content = HttpUtil.getUrlContent(AppConfig.IP_API_URL, 5000, httpPort) ?: return null
+
+ var ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
+ var ip = ipInfo.ip ?: ipInfo.clientIp ?: ipInfo.ip_addr ?: ipInfo.query
+ var country = ipInfo.country_code ?: ipInfo.country ?: ipInfo.countryCode
+
+ return "(${country ?: "unknown"}) $ip"
+ }
+
+ /**
+ * Gets the version of the V2Ray library.
+ *
+ * @return The version of the V2Ray library.
+ */
+ fun getLibVersion(): String {
+ return Libv2ray.checkVersionX()
+ }
+
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt
new file mode 100644
index 00000000..37b55c2e
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/UpdateCheckerManager.kt
@@ -0,0 +1,107 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.dto.CheckUpdateResult
+import com.v2ray.ang.dto.GitHubRelease
+import com.v2ray.ang.extension.concatUrl
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.JsonUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+
+object UpdateCheckerManager {
+ suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
+ val url = if (includePreRelease) {
+ AppConfig.APP_API_URL
+ } else {
+ AppConfig.APP_API_URL.concatUrl("latest")
+ }
+
+ var response = HttpUtil.getUrlContent(url, 5000)
+ if (response.isNullOrEmpty()) {
+ val httpPort = SettingsManager.getHttpPort()
+ response = HttpUtil.getUrlContent(url, 5000, httpPort) ?: throw IllegalStateException("Failed to get response")
+ }
+
+ val latestRelease = if (includePreRelease) {
+ JsonUtil.fromJson(response, Array::class.java)
+ .firstOrNull()
+ ?: throw IllegalStateException("No pre-release found")
+ } else {
+ JsonUtil.fromJson(response, GitHubRelease::class.java)
+ }
+
+ val latestVersion = latestRelease.tagName.removePrefix("v")
+ Log.i(AppConfig.TAG, "Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})")
+
+ return@withContext if (compareVersions(latestVersion, BuildConfig.VERSION_NAME) > 0) {
+ val downloadUrl = getDownloadUrl(latestRelease, Build.SUPPORTED_ABIS[0])
+ CheckUpdateResult(
+ hasUpdate = true,
+ latestVersion = latestVersion,
+ releaseNotes = latestRelease.body,
+ downloadUrl = downloadUrl,
+ isPreRelease = latestRelease.prerelease
+ )
+ } else {
+ CheckUpdateResult(hasUpdate = false)
+ }
+ }
+
+ suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
+ try {
+ val httpPort = SettingsManager.getHttpPort()
+ val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
+ ?: throw IllegalStateException("Failed to create connection")
+
+ try {
+ val apkFile = File(context.cacheDir, "update.apk")
+ Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
+
+ FileOutputStream(apkFile).use { outputStream ->
+ connection.inputStream.use { inputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+ Log.i(AppConfig.TAG, "APK download completed")
+ return@withContext apkFile
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
+ return@withContext null
+ } finally {
+ try {
+ connection.disconnect()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
+ return@withContext null
+ }
+ }
+
+ private fun compareVersions(version1: String, version2: String): Int {
+ val v1 = version1.split(".")
+ val v2 = version2.split(".")
+
+ for (i in 0 until maxOf(v1.size, v2.size)) {
+ val num1 = if (i < v1.size) v1[i].toInt() else 0
+ val num2 = if (i < v2.size) v2[i].toInt() else 0
+ if (num1 != num2) return num1 - num2
+ }
+ return 0
+ }
+
+ private fun getDownloadUrl(release: GitHubRelease, abi: String): String {
+ return release.assets.find { it.name.contains(abi) }?.browserDownloadUrl
+ ?: release.assets.firstOrNull()?.browserDownloadUrl
+ ?: throw IllegalStateException("No compatible APK found")
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt
new file mode 100644
index 00000000..f53697bb
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt
@@ -0,0 +1,1091 @@
+package com.v2ray.ang.handler
+
+import android.content.Context
+import android.text.TextUtils
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.dto.ConfigResult
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.NetworkType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.RulesetItem
+import com.v2ray.ang.dto.V2rayConfig
+import com.v2ray.ang.dto.V2rayConfig.OutboundBean
+import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean
+import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
+import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.fmt.HttpFmt
+import com.v2ray.ang.fmt.ShadowsocksFmt
+import com.v2ray.ang.fmt.SocksFmt
+import com.v2ray.ang.fmt.TrojanFmt
+import com.v2ray.ang.fmt.VlessFmt
+import com.v2ray.ang.fmt.VmessFmt
+import com.v2ray.ang.fmt.WireguardFmt
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+
+object V2rayConfigManager {
+ private var initConfigCache: String? = null
+
+ //region get config function
+
+ /**
+ * Retrieves the V2ray configuration for the given GUID.
+ *
+ * @param context The context of the caller.
+ * @param guid The unique identifier for the V2ray configuration.
+ * @return A ConfigResult object containing the configuration details or indicating failure.
+ */
+ fun getV2rayConfig(context: Context, guid: String): ConfigResult {
+ try {
+ val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
+ return if (config.configType == EConfigType.CUSTOM) {
+ getV2rayCustomConfig(guid, config)
+ } else {
+ getV2rayNormalConfig(context, guid, config)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to get V2ray config", e)
+ return ConfigResult(false)
+ }
+ }
+
+ /**
+ * Retrieves the speedtest V2ray configuration for the given GUID.
+ *
+ * @param context The context of the caller.
+ * @param guid The unique identifier for the V2ray configuration.
+ * @return A ConfigResult object containing the configuration details or indicating failure.
+ */
+ fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
+ try {
+ val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
+ return if (config.configType == EConfigType.CUSTOM) {
+ getV2rayCustomConfig(guid, config)
+ } else {
+ getV2rayNormalConfig4Speedtest(context, guid, config)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
+ return ConfigResult(false)
+ }
+ }
+
+ /**
+ * Retrieves the custom V2ray configuration.
+ *
+ * @param guid The unique identifier for the V2ray configuration.
+ * @param config The profile item containing the configuration details.
+ * @return A ConfigResult object containing the result of the configuration retrieval.
+ */
+ private fun getV2rayCustomConfig(guid: String, config: ProfileItem): ConfigResult {
+ val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
+ return ConfigResult(true, guid, raw)
+ }
+
+ /**
+ * Retrieves the normal V2ray configuration.
+ *
+ * @param context The context in which the function is called.
+ * @param guid The unique identifier for the V2ray configuration.
+ * @param config The profile item containing the configuration details.
+ * @return A ConfigResult object containing the result of the configuration retrieval.
+ */
+ private fun getV2rayNormalConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
+ val result = ConfigResult(false)
+
+ val address = config.server ?: return result
+ if (!Utils.isPureIpAddress(address)) {
+ if (!Utils.isValidUrl(address)) {
+ Log.w(AppConfig.TAG, "$address is an invalid ip or domain")
+ return result
+ }
+ }
+
+ val v2rayConfig = initV2rayConfig(context) ?: return result
+ v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
+ v2rayConfig.remarks = config.remarks
+
+ getInbounds(v2rayConfig)
+
+ if (config.configType == EConfigType.HYSTERIA2) {
+ result.socksPort = getPlusOutbounds(v2rayConfig, config) ?: return result
+ } else {
+ getOutbounds(v2rayConfig, config) ?: return result
+ getMoreOutbounds(v2rayConfig, config.subscriptionId)
+ }
+
+ getRouting(v2rayConfig)
+
+ getFakeDns(v2rayConfig)
+
+ getDns(v2rayConfig)
+
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
+ getCustomLocalDns(v2rayConfig)
+ }
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
+ v2rayConfig.stats = null
+ v2rayConfig.policy = null
+ }
+
+ //Resolve and add to DNS Hosts
+ if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
+ resolveOutboundDomainsToHosts(v2rayConfig)
+ }
+
+ result.status = true
+ result.content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
+ result.guid = guid
+ return result
+ }
+
+ /**
+ * Retrieves the normal V2ray configuration for speedtest.
+ *
+ * @param context The context in which the function is called.
+ * @param guid The unique identifier for the V2ray configuration.
+ * @param config The profile item containing the configuration details.
+ * @return A ConfigResult object containing the result of the configuration retrieval.
+ */
+ private fun getV2rayNormalConfig4Speedtest(context: Context, guid: String, config: ProfileItem): ConfigResult {
+ val result = ConfigResult(false)
+
+ val address = config.server ?: return result
+ if (!Utils.isPureIpAddress(address)) {
+ if (!Utils.isValidUrl(address)) {
+ Log.w(AppConfig.TAG, "$address is an invalid ip or domain")
+ return result
+ }
+ }
+
+ val v2rayConfig = initV2rayConfig(context) ?: return result
+
+ if (config.configType == EConfigType.HYSTERIA2) {
+ result.socksPort = getPlusOutbounds(v2rayConfig, config) ?: return result
+ } else {
+ getOutbounds(v2rayConfig, config) ?: return result
+ getMoreOutbounds(v2rayConfig, config.subscriptionId)
+ }
+
+ v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
+ v2rayConfig.inbounds.clear()
+ v2rayConfig.routing.rules.clear()
+ v2rayConfig.dns = null
+ v2rayConfig.fakedns = null
+ v2rayConfig.stats = null
+ v2rayConfig.policy = null
+
+ v2rayConfig.outbounds.forEach { key ->
+ key.mux = null
+ }
+
+ result.status = true
+ result.content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
+ result.guid = guid
+ return result
+ }
+
+ /**
+ * Initializes V2ray configuration.
+ *
+ * This function loads the V2ray configuration from assets or from a cached value.
+ * It first attempts to use the cached configuration if available, otherwise reads
+ * the configuration from the "v2ray_config.json" asset file.
+ *
+ * @param context Android context used to access application assets
+ * @return V2rayConfig object parsed from the JSON configuration, or null if the configuration is empty
+ */
+ private fun initV2rayConfig(context: Context): V2rayConfig? {
+ val assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
+ if (TextUtils.isEmpty(assets)) {
+ return null
+ }
+ initConfigCache = assets
+ val config = JsonUtil.fromJson(assets, V2rayConfig::class.java)
+ return config
+ }
+
+
+ //endregion
+
+
+ //region some sub function
+
+ /**
+ * Configures the inbound settings for V2ray.
+ *
+ * This function sets up the listening ports, sniffing options, and other inbound-related configurations.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ * @return true if inbound configuration was successful, false otherwise
+ */
+ private fun getInbounds(v2rayConfig: V2rayConfig): Boolean {
+ try {
+ val socksPort = SettingsManager.getSocksPort()
+
+ v2rayConfig.inbounds.forEach { curInbound ->
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
+ //bind all inbounds to localhost if the user requests
+ curInbound.listen = AppConfig.LOOPBACK
+ }
+ }
+ v2rayConfig.inbounds[0].port = socksPort
+ val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
+ val sniffAllTlsAndHttp =
+ MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
+ v2rayConfig.inbounds[0].sniffing?.enabled = fakedns || sniffAllTlsAndHttp
+ v2rayConfig.inbounds[0].sniffing?.routeOnly =
+ MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
+ if (!sniffAllTlsAndHttp) {
+ v2rayConfig.inbounds[0].sniffing?.destOverride?.clear()
+ }
+ if (fakedns) {
+ v2rayConfig.inbounds[0].sniffing?.destOverride?.add("fakedns")
+ }
+
+ if (Utils.isXray()) {
+ v2rayConfig.inbounds.removeAt(1)
+ } else {
+ val httpPort = SettingsManager.getHttpPort()
+ v2rayConfig.inbounds[1].port = httpPort
+ }
+
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to configure inbounds", e)
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Configures the fake DNS settings if enabled.
+ *
+ * Adds FakeDNS configuration to v2rayConfig if both local DNS and fake DNS are enabled.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ */
+ private fun getFakeDns(v2rayConfig: V2rayConfig) {
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
+ && MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
+ ) {
+ v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
+ }
+ }
+
+ /**
+ * Configures routing settings for V2ray.
+ *
+ * Sets up the domain strategy and adds routing rules from saved rulesets.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ * @return true if routing configuration was successful, false otherwise
+ */
+ private fun getRouting(v2rayConfig: V2rayConfig): Boolean {
+ try {
+
+ v2rayConfig.routing.domainStrategy =
+ MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
+ ?: "AsIs"
+
+ val rulesetItems = MmkvManager.decodeRoutingRulesets()
+ rulesetItems?.forEach { key ->
+ getRoutingUserRule(key, v2rayConfig)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to configure routing", e)
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Adds a specific ruleset item to the routing configuration.
+ *
+ * @param item The ruleset item to add
+ * @param v2rayConfig The V2ray configuration object to be modified
+ */
+ private fun getRoutingUserRule(item: RulesetItem?, v2rayConfig: V2rayConfig) {
+ try {
+ if (item == null || !item.enabled) {
+ return
+ }
+
+ val rule = JsonUtil.fromJson(JsonUtil.toJson(item), RulesBean::class.java) ?: return
+
+ v2rayConfig.routing.rules.add(rule)
+
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to apply routing user rule", e)
+ }
+ }
+
+ /**
+ * Retrieves domain rules for a specific outbound tag.
+ *
+ * Searches through all rulesets to find domains targeting the specified tag.
+ *
+ * @param tag The outbound tag to search for
+ * @return ArrayList of domain rules matching the tag
+ */
+ private fun getUserRule2Domain(tag: String): ArrayList {
+ val domain = ArrayList()
+
+ val rulesetItems = MmkvManager.decodeRoutingRulesets()
+ rulesetItems?.forEach { key ->
+ if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
+ key.domain?.forEach {
+ if (it != AppConfig.GEOSITE_PRIVATE
+ && (it.startsWith("geosite:") || it.startsWith("domain:"))
+ ) {
+ domain.add(it)
+ }
+ }
+ }
+ }
+
+ return domain
+ }
+
+ /**
+ * Configures custom local DNS settings.
+ *
+ * Sets up DNS inbound, outbound, and routing rules for local DNS resolution.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ * @return true if custom local DNS configuration was successful, false otherwise
+ */
+ private fun getCustomLocalDns(v2rayConfig: V2rayConfig): Boolean {
+ try {
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
+ val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
+ val proxyDomain = getUserRule2Domain(AppConfig.TAG_PROXY)
+ val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
+ // fakedns with all domains to make it always top priority
+ v2rayConfig.dns?.servers?.add(
+ 0,
+ V2rayConfig.DnsBean.ServersBean(
+ address = "fakedns",
+ domains = geositeCn.plus(proxyDomain).plus(directDomain)
+ )
+ )
+ }
+
+ // DNS inbound
+ val remoteDns = SettingsManager.getRemoteDnsServers()
+ if (v2rayConfig.inbounds.none { e -> e.protocol == "dokodemo-door" && e.tag == "dns-in" }) {
+ val dnsInboundSettings = V2rayConfig.InboundBean.InSettingsBean(
+ address = if (Utils.isPureIpAddress(remoteDns.first())) remoteDns.first() else AppConfig.DNS_PROXY,
+ port = 53,
+ network = "tcp,udp"
+ )
+
+ val localDnsPort = Utils.parseInt(
+ MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT),
+ AppConfig.PORT_LOCAL_DNS.toInt()
+ )
+ v2rayConfig.inbounds.add(
+ V2rayConfig.InboundBean(
+ tag = "dns-in",
+ port = localDnsPort,
+ listen = AppConfig.LOOPBACK,
+ protocol = "dokodemo-door",
+ settings = dnsInboundSettings,
+ sniffing = null
+ )
+ )
+ }
+
+ // DNS outbound
+ if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
+ v2rayConfig.outbounds.add(
+ V2rayConfig.OutboundBean(
+ protocol = "dns",
+ tag = "dns-out",
+ settings = null,
+ streamSettings = null,
+ mux = null
+ )
+ )
+ }
+
+ // DNS routing tag
+ v2rayConfig.routing.rules.add(
+ 0, RulesBean(
+ inboundTag = arrayListOf("dns-in"),
+ outboundTag = "dns-out",
+ domain = null
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to configure custom local DNS", e)
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Configures the DNS settings for V2ray.
+ *
+ * Sets up DNS servers, hosts, and routing rules for DNS resolution.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ * @return true if DNS configuration was successful, false otherwise
+ */
+ private fun getDns(v2rayConfig: V2rayConfig): Boolean {
+ try {
+ val hosts = mutableMapOf()
+ val servers = ArrayList()
+
+ //remote Dns
+ val remoteDns = SettingsManager.getRemoteDnsServers()
+ val proxyDomain = getUserRule2Domain(AppConfig.TAG_PROXY)
+ remoteDns.forEach {
+ servers.add(it)
+ }
+ if (proxyDomain.isNotEmpty()) {
+ servers.add(
+ V2rayConfig.DnsBean.ServersBean(
+ address = remoteDns.first(),
+ domains = proxyDomain,
+ )
+ )
+ }
+
+ // domestic DNS
+ val domesticDns = SettingsManager.getDomesticDnsServers()
+ val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
+ val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
+ val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
+ if (directDomain.isNotEmpty()) {
+ servers.add(
+ V2rayConfig.DnsBean.ServersBean(
+ address = domesticDns.first(),
+ domains = directDomain,
+ expectIPs = if (isCnRoutingMode) geoipCn else null,
+ skipFallback = true
+ )
+ )
+ }
+
+ if (Utils.isPureIpAddress(domesticDns.first())) {
+ v2rayConfig.routing.rules.add(
+ 0, RulesBean(
+ outboundTag = AppConfig.TAG_DIRECT,
+ port = "53",
+ ip = arrayListOf(domesticDns.first()),
+ domain = null
+ )
+ )
+ }
+
+ //block dns
+ val blkDomain = getUserRule2Domain(AppConfig.TAG_BLOCKED)
+ if (blkDomain.isNotEmpty()) {
+ hosts.putAll(blkDomain.map { it to AppConfig.LOOPBACK })
+ }
+
+ // hardcode googleapi rule to fix play store problems
+ hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
+
+ // hardcode popular Android Private DNS rule to fix localhost DNS problem
+ hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
+ hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
+ hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
+ hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
+ hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
+ hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
+ hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
+ hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
+
+ //User DNS hosts
+ try {
+ val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
+ if (userHosts.isNotNullEmpty()) {
+ var userHostsMap = userHosts?.split(",")
+ ?.filter { it.isNotEmpty() }
+ ?.filter { it.contains(":") }
+ ?.associate { it.split(":").let { (k, v) -> k to v } }
+ if (userHostsMap != null) hosts.putAll(userHostsMap)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to configure user DNS hosts", e)
+ }
+
+ // DNS dns
+ v2rayConfig.dns = V2rayConfig.DnsBean(
+ servers = servers,
+ hosts = hosts
+ )
+
+ // DNS routing
+ if (Utils.isPureIpAddress(remoteDns.first())) {
+ v2rayConfig.routing.rules.add(
+ 0, RulesBean(
+ outboundTag = AppConfig.TAG_PROXY,
+ port = "53",
+ ip = arrayListOf(remoteDns.first()),
+ domain = null
+ )
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to configure DNS", e)
+ return false
+ }
+ return true
+ }
+
+
+ //endregion
+
+
+ //region outbound related functions
+
+ /**
+ * Configures the primary outbound connection.
+ *
+ * Converts the profile to an outbound configuration and applies global settings.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ * @param config The profile item containing connection details
+ * @return true if outbound configuration was successful, null if there was an error
+ */
+ private fun getOutbounds(v2rayConfig: V2rayConfig, config: ProfileItem): Boolean? {
+ val outbound = convertProfile2Outbound(config) ?: return null
+ val ret = updateOutboundWithGlobalSettings(outbound)
+ if (!ret) return null
+
+ if (v2rayConfig.outbounds.isNotEmpty()) {
+ v2rayConfig.outbounds[0] = outbound
+ } else {
+ v2rayConfig.outbounds.add(outbound)
+ }
+
+ updateOutboundFragment(v2rayConfig)
+ return true
+ }
+
+ /**
+ * Configures special outbound settings for Hysteria2 protocol.
+ *
+ * Creates a SOCKS outbound connection on a free port for protocols requiring special handling.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ * @param config The profile item containing connection details
+ * @return The port number for the SOCKS connection, or null if there was an error
+ */
+ private fun getPlusOutbounds(v2rayConfig: V2rayConfig, config: ProfileItem): Int? {
+ try {
+ val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
+
+ val outboundNew = OutboundBean(
+ mux = null,
+ protocol = EConfigType.SOCKS.name.lowercase(),
+ settings = OutSettingsBean(
+ servers = listOf(
+ OutSettingsBean.ServersBean(
+ address = AppConfig.LOOPBACK,
+ port = socksPort
+ )
+ )
+ )
+ )
+ if (v2rayConfig.outbounds.isNotEmpty()) {
+ v2rayConfig.outbounds[0] = outboundNew
+ } else {
+ v2rayConfig.outbounds.add(outboundNew)
+ }
+
+ return socksPort
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to configure plusOutbound", e)
+ return null
+ }
+ }
+
+ /**
+ * Configures additional outbound connections for proxy chaining.
+ *
+ * Sets up previous and next proxies in a subscription for advanced routing capabilities.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ * @param subscriptionId The subscription ID to look up related proxies
+ * @return true if additional outbounds were configured successfully, false otherwise
+ */
+ private fun getMoreOutbounds(v2rayConfig: V2rayConfig, subscriptionId: String): Boolean {
+ //fragment proxy
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
+ return false
+ }
+
+ if (subscriptionId.isEmpty()) {
+ return false
+ }
+ try {
+ val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return false
+
+ //current proxy
+ val outbound = v2rayConfig.outbounds[0]
+
+ //Previous proxy
+ val prevNode = SettingsManager.getServerViaRemarks(subItem.prevProfile)
+ if (prevNode != null) {
+ val prevOutbound = convertProfile2Outbound(prevNode)
+ if (prevOutbound != null) {
+ updateOutboundWithGlobalSettings(prevOutbound)
+ prevOutbound.tag = AppConfig.TAG_PROXY + "2"
+ v2rayConfig.outbounds.add(prevOutbound)
+ outbound.ensureSockopt().dialerProxy = prevOutbound.tag
+ }
+ }
+
+ //Next proxy
+ val nextNode = SettingsManager.getServerViaRemarks(subItem.nextProfile)
+ if (nextNode != null) {
+ val nextOutbound = convertProfile2Outbound(nextNode)
+ if (nextOutbound != null) {
+ updateOutboundWithGlobalSettings(nextOutbound)
+ nextOutbound.tag = AppConfig.TAG_PROXY
+ v2rayConfig.outbounds.add(0, nextOutbound)
+ outbound.tag = AppConfig.TAG_PROXY + "1"
+ nextOutbound.ensureSockopt().dialerProxy = outbound.tag
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to configure more outbounds", e)
+ return false
+ }
+
+ return true
+ }
+
+ /**
+ * Updates outbound settings based on global preferences.
+ *
+ * Applies multiplexing and protocol-specific settings to an outbound connection.
+ *
+ * @param outbound The outbound connection to update
+ * @return true if the update was successful, false otherwise
+ */
+ private fun updateOutboundWithGlobalSettings(outbound: V2rayConfig.OutboundBean): Boolean {
+ try {
+ var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
+ val protocol = outbound.protocol
+ if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
+ || protocol.equals(EConfigType.SOCKS.name, true)
+ || protocol.equals(EConfigType.HTTP.name, true)
+ || protocol.equals(EConfigType.TROJAN.name, true)
+ || protocol.equals(EConfigType.WIREGUARD.name, true)
+ || protocol.equals(EConfigType.HYSTERIA2.name, true)
+ ) {
+ muxEnabled = false
+ } else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
+ muxEnabled = false
+ }
+
+ if (muxEnabled == true) {
+ outbound.mux?.enabled = true
+ outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
+ outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
+ outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
+ if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
+ outbound.mux?.concurrency = -1
+ }
+ } else {
+ outbound.mux?.enabled = false
+ outbound.mux?.concurrency = -1
+ }
+
+ if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
+ var localTunAddr = if (outbound.settings?.address == null) {
+ listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
+ } else {
+ outbound.settings?.address as List<*>
+ }
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) != true) {
+ localTunAddr = listOf(localTunAddr.first())
+ }
+ outbound.settings?.address = localTunAddr
+ }
+
+ if (outbound.streamSettings?.network == AppConfig.DEFAULT_NETWORK
+ && outbound.streamSettings?.tcpSettings?.header?.type == AppConfig.HEADER_TYPE_HTTP
+ ) {
+ val path = outbound.streamSettings?.tcpSettings?.header?.request?.path
+ val host = outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host
+
+ val requestString: String by lazy {
+ """{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
+ }
+ outbound.streamSettings?.tcpSettings?.header?.request = JsonUtil.fromJson(
+ requestString,
+ StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
+ )
+ outbound.streamSettings?.tcpSettings?.header?.request?.path =
+ if (path.isNullOrEmpty()) {
+ listOf("/")
+ } else {
+ path
+ }
+ outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host = host
+ }
+
+
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Updates the outbound with fragment settings for traffic optimization.
+ *
+ * Configures packet fragmentation for TLS and REALITY protocols if enabled.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ * @return true if fragment configuration was successful, false otherwise
+ */
+ private fun updateOutboundFragment(v2rayConfig: V2rayConfig): Boolean {
+ try {
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
+ return true
+ }
+ if (v2rayConfig.outbounds[0].streamSettings?.security != AppConfig.TLS
+ && v2rayConfig.outbounds[0].streamSettings?.security != AppConfig.REALITY
+ ) {
+ return true
+ }
+
+ val fragmentOutbound =
+ V2rayConfig.OutboundBean(
+ protocol = AppConfig.PROTOCOL_FREEDOM,
+ tag = AppConfig.TAG_FRAGMENT,
+ mux = null
+ )
+
+ var packets =
+ MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
+ if (v2rayConfig.outbounds[0].streamSettings?.security == AppConfig.REALITY
+ && packets == "tlshello"
+ ) {
+ packets = "1-3"
+ } else if (v2rayConfig.outbounds[0].streamSettings?.security == AppConfig.TLS
+ && packets != "tlshello"
+ ) {
+ packets = "tlshello"
+ }
+
+ fragmentOutbound.settings = OutboundBean.OutSettingsBean(
+ fragment = OutboundBean.OutSettingsBean.FragmentBean(
+ packets = packets,
+ length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
+ ?: "50-100",
+ interval = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
+ ?: "10-20"
+ ),
+ noises = listOf(
+ OutboundBean.OutSettingsBean.NoiseBean(
+ type = "rand",
+ packet = "10-20",
+ delay = "10-16",
+ )
+ ),
+ )
+ fragmentOutbound.streamSettings = StreamSettingsBean(
+ sockopt = StreamSettingsBean.SockoptBean(
+ TcpNoDelay = true,
+ mark = 255
+ )
+ )
+ v2rayConfig.outbounds.add(fragmentOutbound)
+
+ //proxy chain
+ v2rayConfig.outbounds[0].streamSettings?.sockopt =
+ StreamSettingsBean.SockoptBean(
+ dialerProxy = AppConfig.TAG_FRAGMENT
+ )
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to update outbound fragment", e)
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Resolves domain names to IP addresses in outbound connections.
+ *
+ * Pre-resolves domains to improve connection speed and reliability.
+ *
+ * @param v2rayConfig The V2ray configuration object to be modified
+ */
+ private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
+ val proxyOutboundList = v2rayConfig.getAllProxyOutbound()
+ val dns = v2rayConfig.dns ?: return
+ val newHosts = dns.hosts?.toMutableMap() ?: mutableMapOf()
+ val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true
+
+ for (item in proxyOutboundList) {
+ val domain = item.getServerAddress()
+ if (domain.isNullOrEmpty()) continue
+
+ if (newHosts.containsKey(domain)) {
+ item.ensureSockopt().domainStrategy = if (preferIpv6) "UseIPv6v4" else "UseIPv4v6"
+ continue
+ }
+
+ val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6)
+ if (resolvedIps.isNullOrEmpty()) continue
+
+ item.ensureSockopt().domainStrategy = if (preferIpv6) "UseIPv6v4" else "UseIPv4v6"
+ newHosts[domain] = if (resolvedIps.size == 1) {
+ resolvedIps[0]
+ } else {
+ resolvedIps
+ }
+ }
+
+ dns.hosts = newHosts
+ }
+
+ /**
+ * Converts a profile item to an outbound configuration.
+ *
+ * Creates appropriate outbound settings based on the protocol type.
+ *
+ * @param profileItem The profile item to convert
+ * @return OutboundBean configuration for the profile, or null if not supported
+ */
+ private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
+ return when (profileItem.configType) {
+ EConfigType.VMESS -> VmessFmt.toOutbound(profileItem)
+ EConfigType.CUSTOM -> null
+ EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toOutbound(profileItem)
+ EConfigType.SOCKS -> SocksFmt.toOutbound(profileItem)
+ EConfigType.VLESS -> VlessFmt.toOutbound(profileItem)
+ EConfigType.TROJAN -> TrojanFmt.toOutbound(profileItem)
+ EConfigType.WIREGUARD -> WireguardFmt.toOutbound(profileItem)
+ EConfigType.HYSTERIA2 -> null
+ EConfigType.HTTP -> HttpFmt.toOutbound(profileItem)
+ }
+ }
+
+ /**
+ * Creates an initial outbound configuration for a specific protocol type.
+ *
+ * Provides a template configuration for different protocol types.
+ *
+ * @param configType The type of configuration to create
+ * @return An initial OutboundBean for the specified configuration type, or null for custom types
+ */
+ fun createInitOutbound(configType: EConfigType): OutboundBean? {
+ return when (configType) {
+ EConfigType.VMESS,
+ EConfigType.VLESS ->
+ return OutboundBean(
+ protocol = configType.name.lowercase(),
+ settings = OutSettingsBean(
+ vnext = listOf(
+ OutSettingsBean.VnextBean(
+ users = listOf(OutSettingsBean.VnextBean.UsersBean())
+ )
+ )
+ ),
+ streamSettings = StreamSettingsBean()
+ )
+
+ EConfigType.SHADOWSOCKS,
+ EConfigType.SOCKS,
+ EConfigType.HTTP,
+ EConfigType.TROJAN,
+ EConfigType.HYSTERIA2 ->
+ return OutboundBean(
+ protocol = configType.name.lowercase(),
+ settings = OutSettingsBean(
+ servers = listOf(OutSettingsBean.ServersBean())
+ ),
+ streamSettings = StreamSettingsBean()
+ )
+
+ EConfigType.WIREGUARD ->
+ return OutboundBean(
+ protocol = configType.name.lowercase(),
+ settings = OutSettingsBean(
+ secretKey = "",
+ peers = listOf(OutSettingsBean.WireGuardBean())
+ )
+ )
+
+ EConfigType.CUSTOM -> null
+ }
+ }
+
+ /**
+ * Configures transport settings for an outbound connection.
+ *
+ * Sets up protocol-specific transport options based on the profile settings.
+ *
+ * @param streamSettings The stream settings to configure
+ * @param profileItem The profile containing transport configuration
+ * @return The Server Name Indication (SNI) value to use, or null if not applicable
+ */
+ fun populateTransportSettings(streamSettings: StreamSettingsBean, profileItem: ProfileItem): String? {
+ val transport = profileItem.network.orEmpty()
+ val headerType = profileItem.headerType
+ val host = profileItem.host
+ val path = profileItem.path
+ val seed = profileItem.seed
+// val quicSecurity = profileItem.quicSecurity
+// val key = profileItem.quicKey
+ val mode = profileItem.mode
+ val serviceName = profileItem.serviceName
+ val authority = profileItem.authority
+ val xhttpMode = profileItem.xhttpMode
+ val xhttpExtra = profileItem.xhttpExtra
+
+ var sni: String? = null
+ streamSettings.network = if (transport.isEmpty()) NetworkType.TCP.type else transport
+ when (streamSettings.network) {
+ NetworkType.TCP.type -> {
+ val tcpSetting = StreamSettingsBean.TcpSettingsBean()
+ if (headerType == AppConfig.HEADER_TYPE_HTTP) {
+ tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
+ if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
+ val requestObj = StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
+ requestObj.headers.Host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ requestObj.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ tcpSetting.header.request = requestObj
+ sni = requestObj.headers.Host?.getOrNull(0)
+ }
+ } else {
+ tcpSetting.header.type = "none"
+ sni = host
+ }
+ streamSettings.tcpSettings = tcpSetting
+ }
+
+ NetworkType.KCP.type -> {
+ val kcpsetting = StreamSettingsBean.KcpSettingsBean()
+ kcpsetting.header.type = headerType ?: "none"
+ if (seed.isNullOrEmpty()) {
+ kcpsetting.seed = null
+ } else {
+ kcpsetting.seed = seed
+ }
+ if (host.isNullOrEmpty()) {
+ kcpsetting.header.domain = null
+ } else {
+ kcpsetting.header.domain = host
+ }
+ streamSettings.kcpSettings = kcpsetting
+ }
+
+ NetworkType.WS.type -> {
+ val wssetting = StreamSettingsBean.WsSettingsBean()
+ wssetting.headers.Host = host.orEmpty()
+ sni = host
+ wssetting.path = path ?: "/"
+ streamSettings.wsSettings = wssetting
+ }
+
+ NetworkType.HTTP_UPGRADE.type -> {
+ val httpupgradeSetting = StreamSettingsBean.HttpupgradeSettingsBean()
+ httpupgradeSetting.host = host.orEmpty()
+ sni = host
+ httpupgradeSetting.path = path ?: "/"
+ streamSettings.httpupgradeSettings = httpupgradeSetting
+ }
+
+ NetworkType.XHTTP.type -> {
+ val xhttpSetting = StreamSettingsBean.XhttpSettingsBean()
+ xhttpSetting.host = host.orEmpty()
+ sni = host
+ xhttpSetting.path = path ?: "/"
+ xhttpSetting.mode = xhttpMode
+ xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
+ streamSettings.xhttpSettings = xhttpSetting
+ }
+
+ NetworkType.H2.type, NetworkType.HTTP.type -> {
+ streamSettings.network = NetworkType.H2.type
+ val h2Setting = StreamSettingsBean.HttpSettingsBean()
+ h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ sni = h2Setting.host.getOrNull(0)
+ h2Setting.path = path ?: "/"
+ streamSettings.httpSettings = h2Setting
+ }
+
+// "quic" -> {
+// val quicsetting = QuicSettingBean()
+// quicsetting.security = quicSecurity ?: "none"
+// quicsetting.key = key.orEmpty()
+// quicsetting.header.type = headerType ?: "none"
+// quicSettings = quicsetting
+// }
+
+ NetworkType.GRPC.type -> {
+ val grpcSetting = StreamSettingsBean.GrpcSettingsBean()
+ grpcSetting.multiMode = mode == "multi"
+ grpcSetting.serviceName = serviceName.orEmpty()
+ grpcSetting.authority = authority.orEmpty()
+ grpcSetting.idle_timeout = 60
+ grpcSetting.health_check_timeout = 20
+ sni = authority
+ streamSettings.grpcSettings = grpcSetting
+ }
+ }
+ return sni
+ }
+
+ /**
+ * Configures TLS or REALITY security settings for an outbound connection.
+ *
+ * Sets up security-related parameters like certificates, fingerprints, and SNI.
+ *
+ * @param streamSettings The stream settings to configure
+ * @param profileItem The profile containing security configuration
+ * @param sniExt An external SNI value to use if the profile doesn't specify one
+ */
+ fun populateTlsSettings(streamSettings: StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
+ val streamSecurity = profileItem.security.orEmpty()
+ val allowInsecure = profileItem.insecure == true
+ val sni = if (profileItem.sni.isNullOrEmpty()) {
+ when {
+ sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
+ profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
+ else -> sniExt
+ }
+ } else {
+ profileItem.sni
+ }
+ val fingerprint = profileItem.fingerPrint
+ val alpns = profileItem.alpn
+ val publicKey = profileItem.publicKey
+ val shortId = profileItem.shortId
+ val spiderX = profileItem.spiderX
+
+ streamSettings.security = if (streamSecurity.isEmpty()) null else streamSecurity
+ if (streamSettings.security == null) return
+ val tlsSetting = StreamSettingsBean.TlsSettingsBean(
+ allowInsecure = allowInsecure,
+ serverName = if (sni.isNullOrEmpty()) null else sni,
+ fingerprint = if (fingerprint.isNullOrEmpty()) null else fingerprint,
+ alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() },
+ publicKey = if (publicKey.isNullOrEmpty()) null else publicKey,
+ shortId = if (shortId.isNullOrEmpty()) null else shortId,
+ spiderX = if (spiderX.isNullOrEmpty()) null else spiderX,
+ )
+ if (streamSettings.security == AppConfig.TLS) {
+ streamSettings.tlsSettings = tlsSetting
+ streamSettings.realitySettings = null
+ } else if (streamSettings.security == AppConfig.REALITY) {
+ streamSettings.tlsSettings = null
+ streamSettings.realitySettings = tlsSetting
+ }
+ }
+
+ //endregion
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt
new file mode 100644
index 00000000..0f6d37c0
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt
@@ -0,0 +1,68 @@
+package com.v2ray.ang.helper
+
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+class CustomDividerItemDecoration(
+ private val divider: Drawable,
+ private val orientation: Int
+) : RecyclerView.ItemDecoration() {
+
+ override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
+ if (orientation == RecyclerView.VERTICAL) {
+ drawVerticalDividers(canvas, parent)
+ } else {
+ drawHorizontalDividers(canvas, parent)
+ }
+ }
+
+ private fun drawVerticalDividers(canvas: Canvas, parent: RecyclerView) {
+ val left = parent.paddingLeft
+ val right = parent.width - parent.paddingRight
+
+ val childCount = parent.childCount
+ for (i in 0 until childCount - 1) {
+ val child = parent.getChildAt(i)
+ val params = child.layoutParams as RecyclerView.LayoutParams
+
+ val top = child.bottom + params.bottomMargin
+ val bottom = top + divider.intrinsicHeight
+
+ divider.setBounds(left, top, right, bottom)
+ divider.draw(canvas)
+ }
+ }
+
+ private fun drawHorizontalDividers(canvas: Canvas, parent: RecyclerView) {
+ val top = parent.paddingTop
+ val bottom = parent.height - parent.paddingBottom
+
+ val childCount = parent.childCount
+ for (i in 0 until childCount - 1) {
+ val child = parent.getChildAt(i)
+ val params = child.layoutParams as RecyclerView.LayoutParams
+
+ val left = child.right + params.rightMargin
+ val right = left + divider.intrinsicWidth
+
+ divider.setBounds(left, top, right, bottom)
+ divider.draw(canvas)
+ }
+ }
+
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ if (orientation == RecyclerView.VERTICAL) {
+ outRect.set(0, 0, 0, divider.intrinsicHeight)
+ } else {
+ outRect.set(0, 0, divider.intrinsicWidth, 0)
+ }
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt
similarity index 53%
rename from V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java
rename to V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt
index 2de0c74a..8707d16f 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.java
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt
@@ -13,48 +13,41 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-package com.v2ray.ang.helper;
-
-import androidx.recyclerview.widget.RecyclerView;
-import androidx.recyclerview.widget.ItemTouchHelper;
+package com.v2ray.ang.helper
/**
- * Interface to listen for a move or dismissal event from a {@link ItemTouchHelper.Callback}.
+ * Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
*
* @author Paul Burke (ipaulpro)
*/
-public interface ItemTouchHelperAdapter {
-
+interface ItemTouchHelperAdapter {
/**
* Called when an item has been dragged far enough to trigger a move. This is called every time
- * an item is shifted, and not at the end of a "drop" event.
- *
- * Implementations should call {@link RecyclerView.Adapter#notifyItemMoved(int, int)} after
+ * an item is shifted, and **not** at the end of a "drop" event.
+ *
+ * Implementations should call [RecyclerView.Adapter.notifyItemMoved] after
* adjusting the underlying data to reflect this move.
*
* @param fromPosition The start position of the moved item.
* @param toPosition Then resolved position of the moved item.
* @return True if the item was moved to the new adapter position.
- *
- * @see RecyclerView#getAdapterPositionFor(RecyclerView.ViewHolder)
- * @see RecyclerView.ViewHolder#getAdapterPosition()
+ * @see RecyclerView.getAdapterPositionFor
+ * @see RecyclerView.ViewHolder.getAdapterPosition
*/
- boolean onItemMove(int fromPosition, int toPosition);
+ fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
- void onItemMoveCompleted();
+ fun onItemMoveCompleted()
/**
- * Called when an item has been dismissed by a swipe.
- *
- * Implementations should call {@link RecyclerView.Adapter#notifyItemRemoved(int)} after
+ * Called when an item has been dismissed by a swipe.
+ *
+ * Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after
* adjusting the underlying data to reflect this removal.
*
* @param position The position of the item dismissed.
- *
- * @see RecyclerView#getAdapterPositionFor(RecyclerView.ViewHolder)
- * @see RecyclerView.ViewHolder#getAdapterPosition()
+ * @see RecyclerView.getAdapterPositionFor
+ * @see RecyclerView.ViewHolder.getAdapterPosition
*/
- void onItemDismiss(int position);
+ fun onItemDismiss(position: Int)
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt
similarity index 67%
rename from V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java
rename to V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt
index 149768fc..75655b0c 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.java
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt
@@ -13,29 +13,26 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package com.v2ray.ang.helper
-package com.v2ray.ang.helper;
-
-import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.ItemTouchHelper
/**
- * Interface to notify an item ViewHolder of relevant callbacks from {@link
- * ItemTouchHelper.Callback}.
+ * Interface to notify an item ViewHolder of relevant callbacks from [ ].
*
* @author Paul Burke (ipaulpro)
*/
-public interface ItemTouchHelperViewHolder {
-
+interface ItemTouchHelperViewHolder {
/**
- * Called when the {@link ItemTouchHelper} first registers an item as being moved or swiped.
+ * Called when the [ItemTouchHelper] first registers an item as being moved or swiped.
* Implementations should update the item view to indicate it's active state.
*/
- void onItemSelected();
+ fun onItemSelected()
/**
- * Called when the {@link ItemTouchHelper} has completed the move or swipe, and the active item
+ * Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
* state should be cleared.
*/
- void onItemClear();
+ fun onItemClear()
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/OnStartDragListener.java b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/OnStartDragListener.java
deleted file mode 100644
index a6407b6f..00000000
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/OnStartDragListener.java
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright (C) 2015 Paul Burke
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.v2ray.ang.helper;
-
-import androidx.recyclerview.widget.RecyclerView;
-
-/**
- * Listener for manual initiation of a drag.
- */
-public interface OnStartDragListener {
-
- /**
- * Called when a view is requesting a start of a drag.
- *
- * @param viewHolder The holder of the view to drag.
- */
- void onStartDrag(RecyclerView.ViewHolder viewHolder);
-
-}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java
deleted file mode 100644
index 97fceab7..00000000
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.java
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- * Copyright (C) 2015 Paul Burke
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.v2ray.ang.helper;
-
-import android.graphics.Canvas;
-import androidx.recyclerview.widget.GridLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-import androidx.recyclerview.widget.ItemTouchHelper;
-
-import org.jetbrains.annotations.NotNull;
-
-/**
- * An implementation of {@link ItemTouchHelper.Callback} that enables basic drag & drop and
- * swipe-to-dismiss. Drag events are automatically started by an item long-press.
- *
- * Expects the RecyclerView.Adapter
to listen for {@link
- * ItemTouchHelperAdapter} callbacks and the RecyclerView.ViewHolder
to implement
- * {@link ItemTouchHelperViewHolder}.
- *
- * @author Paul Burke (ipaulpro)
- */
-public class SimpleItemTouchHelperCallback extends ItemTouchHelper.Callback {
-
- public static final float ALPHA_FULL = 1.0f;
-
- private final ItemTouchHelperAdapter mAdapter;
-
- public SimpleItemTouchHelperCallback(ItemTouchHelperAdapter adapter) {
- mAdapter = adapter;
- }
-
- @Override
- public boolean isLongPressDragEnabled() {
- return true;
- }
-
- @Override
- public boolean isItemViewSwipeEnabled() {
- return false;
- }
-
- @Override
- public int getMovementFlags(RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder) {
- // Set movement flags based on the layout manager
- if (recyclerView.getLayoutManager() instanceof GridLayoutManager) {
- final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
- final int swipeFlags = 0;
- return makeMovementFlags(dragFlags, swipeFlags);
- } else {
- final int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
- final int swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END;
- return makeMovementFlags(dragFlags, swipeFlags);
- }
- }
-
- @Override
- public boolean onMove(@NotNull RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) {
- if (source.getItemViewType() != target.getItemViewType()) {
- return false;
- }
-
- // Notify the adapter of the move
- mAdapter.onItemMove(source.getBindingAdapterPosition(), target.getBindingAdapterPosition());
- return true;
- }
-
- @Override
- public void onSwiped(RecyclerView.ViewHolder viewHolder, int i) {
- // Notify the adapter of the dismissal
- mAdapter.onItemDismiss(viewHolder.getBindingAdapterPosition());
- }
-
- @Override
- public void onChildDraw(@NotNull Canvas c, @NotNull RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder, float dX,
- float dY, int actionState, boolean isCurrentlyActive) {
- if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
- // Fade out the view as it is swiped out of the parent's bounds
- final float alpha = ALPHA_FULL - Math.abs(dX) / (float) viewHolder.itemView.getWidth();
- viewHolder.itemView.setAlpha(alpha);
- viewHolder.itemView.setTranslationX(dX);
- } else {
- super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
- }
- }
-
- @Override
- public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
- // We only want the active item to change
- if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) {
- if (viewHolder instanceof ItemTouchHelperViewHolder) {
- // Let the view holder know that this item is being moved or dragged
- ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder;
- itemViewHolder.onItemSelected();
- }
- }
-
- super.onSelectedChanged(viewHolder, actionState);
- }
-
- @Override
- public void clearView(@NotNull RecyclerView recyclerView, @NotNull RecyclerView.ViewHolder viewHolder) {
- super.clearView(recyclerView, viewHolder);
-
- mAdapter.onItemMoveCompleted();
-
- viewHolder.itemView.setAlpha(ALPHA_FULL);
-
- if (viewHolder instanceof ItemTouchHelperViewHolder) {
- // Tell the view holder it's time to restore the idle state
- ItemTouchHelperViewHolder itemViewHolder = (ItemTouchHelperViewHolder) viewHolder;
- itemViewHolder.onItemClear();
- }
- }
-}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt
new file mode 100644
index 00000000..b98129a7
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2015 Paul Burke
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.v2ray.ang.helper
+
+import android.animation.ValueAnimator
+import android.graphics.Canvas
+import android.view.animation.DecelerateInterpolator
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+import kotlin.math.abs
+import kotlin.math.min
+import kotlin.math.sign
+
+/**
+ * An implementation of [ItemTouchHelper.Callback] that enables basic drag & drop and
+ * swipe-to-dismiss. Drag events are automatically started by an item long-press.
+ *
+ * Expects the `RecyclerView.Adapter` to listen for [ ] callbacks and the `RecyclerView.ViewHolder` to implement
+ * [ItemTouchHelperViewHolder].
+ *
+ * @author Paul Burke (ipaulpro)
+ */
+class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter) : ItemTouchHelper.Callback() {
+ private var mReturnAnimator: ValueAnimator? = null
+
+ override fun isLongPressDragEnabled(): Boolean = true
+
+ override fun isItemViewSwipeEnabled(): Boolean = true
+
+ override fun getMovementFlags(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder
+ ): Int {
+ val dragFlags: Int
+ val swipeFlags: Int
+ if (recyclerView.layoutManager is GridLayoutManager) {
+ dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
+ swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
+ } else {
+ dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
+ swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
+ }
+ return makeMovementFlags(dragFlags, swipeFlags)
+ }
+
+ override fun onMove(
+ recyclerView: RecyclerView,
+ source: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ): Boolean {
+ return if (source.itemViewType != target.itemViewType) {
+ false
+ } else {
+ mAdapter.onItemMove(source.bindingAdapterPosition, target.bindingAdapterPosition)
+ true
+ }
+ }
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ // Do not delete; simply return item to original position
+ returnViewToOriginalPosition(viewHolder)
+ }
+
+ override fun onChildDraw(
+ c: Canvas, recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean
+ ) {
+ if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
+ val maxSwipeDistance = viewHolder.itemView.width * SWIPE_THRESHOLD
+ val swipeAmount = abs(dX)
+ val direction = sign(dX)
+
+ // Limit maximum swipe distance
+ val translationX = min(swipeAmount, maxSwipeDistance) * direction
+ val alpha = ALPHA_FULL - min(swipeAmount, maxSwipeDistance) / maxSwipeDistance
+
+ viewHolder.itemView.translationX = translationX
+ viewHolder.itemView.alpha = alpha
+
+ if (swipeAmount >= maxSwipeDistance && isCurrentlyActive) {
+ returnViewToOriginalPosition(viewHolder)
+ }
+ } else {
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+ }
+ }
+
+ private fun returnViewToOriginalPosition(viewHolder: RecyclerView.ViewHolder) {
+ mReturnAnimator?.takeIf { it.isRunning }?.cancel()
+
+ mReturnAnimator = ValueAnimator.ofFloat(viewHolder.itemView.translationX, 0f).apply {
+ addUpdateListener { animation ->
+ val value = animation.animatedValue as Float
+ viewHolder.itemView.translationX = value
+ viewHolder.itemView.alpha = 1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD)
+ }
+ interpolator = DecelerateInterpolator()
+ duration = ANIMATION_DURATION
+ start()
+ }
+ }
+
+ override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
+ if (actionState != ItemTouchHelper.ACTION_STATE_IDLE && viewHolder is ItemTouchHelperViewHolder) {
+ viewHolder.onItemSelected()
+ }
+ super.onSelectedChanged(viewHolder, actionState)
+ }
+
+ override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
+ super.clearView(recyclerView, viewHolder)
+ viewHolder.itemView.alpha = ALPHA_FULL
+ if (viewHolder is ItemTouchHelperViewHolder) {
+ viewHolder.onItemClear()
+ }
+ mAdapter.onItemMoveCompleted()
+ }
+
+ override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
+ return 1.1f // Set a value greater than 1 to prevent default swipe delete
+ }
+
+ override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
+ return defaultValue * 10 // Increase swipe escape velocity to make swipe harder to trigger
+ }
+
+ companion object {
+ private const val ALPHA_FULL = 1.0f
+ private const val SWIPE_THRESHOLD = 0.25f
+ private const val ANIMATION_DURATION: Long = 200
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/NativePlugin.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/NativePlugin.kt
new file mode 100644
index 00000000..7a072cd0
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/NativePlugin.kt
@@ -0,0 +1,32 @@
+/******************************************************************************
+ * *
+ * Copyright (C) 2021 by nekohasekai *
+ * Copyright (C) 2021 by Max Lv *
+ * Copyright (C) 2021 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ ******************************************************************************/
+
+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!!
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/Plugin.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/Plugin.kt
new file mode 100644
index 00000000..04294ac6
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/Plugin.kt
@@ -0,0 +1,43 @@
+/******************************************************************************
+ * *
+ * Copyright (C) 2021 by nekohasekai *
+ * Copyright (C) 2021 by Max Lv *
+ * Copyright (C) 2021 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ ******************************************************************************/
+
+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()
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginContract.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginContract.kt
new file mode 100644
index 00000000..5aa253f8
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginContract.kt
@@ -0,0 +1,33 @@
+/******************************************************************************
+ * *
+ * Copyright (C) 2021 by nekohasekai *
+ * *
+ * 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 . *
+ * *
+ ******************************************************************************/
+
+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"
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginList.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginList.kt
new file mode 100644
index 00000000..2495eb45
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginList.kt
@@ -0,0 +1,54 @@
+/******************************************************************************
+ * *
+ * Copyright (C) 2021 by nekohasekai *
+ * Copyright (C) 2021 by Max Lv *
+ * Copyright (C) 2021 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ ******************************************************************************/
+
+package com.v2ray.ang.plugin
+
+import android.content.Intent
+import android.content.pm.PackageManager
+import com.v2ray.ang.AngApplication
+
+class PluginList : ArrayList() {
+ 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().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))
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginManager.kt
new file mode 100644
index 00000000..e7e8457a
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/PluginManager.kt
@@ -0,0 +1,233 @@
+/******************************************************************************
+ * *
+ * Copyright (C) 2021 by nekohasekai *
+ * Copyright (C) 2021 by Max Lv *
+ * Copyright (C) 2021 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ ******************************************************************************/
+
+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
+ else -> error("meta-data $key has invalid type ${value.javaClass}")
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/ResolvedPlugin.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/ResolvedPlugin.kt
new file mode 100644
index 00000000..6e6861cf
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/plugin/ResolvedPlugin.kt
@@ -0,0 +1,51 @@
+/******************************************************************************
+ * *
+ * Copyright (C) 2021 by nekohasekai *
+ * Copyright (C) 2021 by Max Lv *
+ * Copyright (C) 2021 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ ******************************************************************************/
+
+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
+ )!!
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt
new file mode 100644
index 00000000..ae15e76c
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/BootReceiver.kt
@@ -0,0 +1,23 @@
+package com.v2ray.ang.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.service.V2RayServiceManager
+
+class BootReceiver : BroadcastReceiver() {
+ /**
+ * This method is called when the BroadcastReceiver is receiving an Intent broadcast.
+ * It checks if the context is not null and the action is ACTION_BOOT_COMPLETED.
+ * If the conditions are met, it starts the V2Ray service.
+ *
+ * @param context The Context in which the receiver is running.
+ * @param intent The Intent being received.
+ */
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
+ if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
+ V2RayServiceManager.startVService(context)
+ }
+}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/TaskerReceiver.kt
similarity index 50%
rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt
rename to V2rayNG/app/src/main/java/com/v2ray/ang/receiver/TaskerReceiver.kt
index 08a3184a..bdb7bf9a 100644
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/TaskerReceiver.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/TaskerReceiver.kt
@@ -4,38 +4,38 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.text.TextUtils
-import com.google.zxing.WriterException
-import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig
import com.v2ray.ang.service.V2RayServiceManager
-import com.v2ray.ang.util.MmkvManager
-
-import com.v2ray.ang.util.Utils
class TaskerReceiver : BroadcastReceiver() {
- private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
+ /**
+ * This method is called when the BroadcastReceiver is receiving an Intent broadcast.
+ * It retrieves the bundle from the intent and checks the switch and guid values.
+ * Depending on the switch value, it starts or stops the V2Ray service.
+ *
+ * @param context The Context in which the receiver is running.
+ * @param intent The Intent being received.
+ */
override fun onReceive(context: Context, intent: Intent?) {
-
try {
val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
val switch = bundle?.getBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, false)
- val guid = bundle?.getString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, "")
+ val guid = bundle?.getString(AppConfig.TASKER_EXTRA_BUNDLE_GUID).orEmpty()
- if (switch == null || guid == null || TextUtils.isEmpty(guid)) {
+ if (switch == null || TextUtils.isEmpty(guid)) {
return
} else if (switch) {
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
- Utils.startVServiceFromToggle(context)
+ V2RayServiceManager.startVServiceFromToggle(context)
} else {
- mainStorage?.encode(MmkvManager.KEY_SELECTED_SERVER, guid)
- V2RayServiceManager.startV2Ray(context)
+ V2RayServiceManager.startVService(context, guid)
}
} else {
- Utils.stopVService(context)
+ V2RayServiceManager.stopVService(context)
}
- } catch (e: WriterException) {
- e.printStackTrace()
+ } catch (e: Exception) {
+ android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
}
}
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/WidgetProvider.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/WidgetProvider.kt
new file mode 100644
index 00000000..99d9bda2
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/receiver/WidgetProvider.kt
@@ -0,0 +1,100 @@
+package com.v2ray.ang.receiver
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.widget.RemoteViews
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.service.V2RayServiceManager
+
+class WidgetProvider : AppWidgetProvider() {
+ /**
+ * This method is called every time the widget is updated.
+ * It updates the widget background based on the V2Ray service running state.
+ *
+ * @param context The Context in which the receiver is running.
+ * @param appWidgetManager The AppWidgetManager instance.
+ * @param appWidgetIds The appWidgetIds for which an update is needed.
+ */
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.isRunning())
+ }
+
+ /**
+ * Updates the widget background based on whether the V2Ray service is running.
+ *
+ * @param context The Context in which the receiver is running.
+ * @param appWidgetManager The AppWidgetManager instance.
+ * @param appWidgetIds The appWidgetIds for which an update is needed.
+ * @param isRunning Boolean indicating if the V2Ray service is running.
+ */
+ private fun updateWidgetBackground(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, isRunning: Boolean) {
+ val remoteViews = RemoteViews(context.packageName, R.layout.widget_switch)
+ val intent = Intent(context, WidgetProvider::class.java)
+ intent.action = AppConfig.BROADCAST_ACTION_WIDGET_CLICK
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ R.id.layout_switch,
+ intent,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+ )
+ remoteViews.setOnClickPendingIntent(R.id.layout_switch, pendingIntent)
+ if (isRunning) {
+ remoteViews.setInt(R.id.image_switch, "setImageResource", R.drawable.ic_stop_24dp)
+ remoteViews.setInt(R.id.layout_background, "setBackgroundResource", R.drawable.ic_rounded_corner_active)
+ } else {
+ remoteViews.setInt(R.id.image_switch, "setImageResource", R.drawable.ic_play_24dp)
+ remoteViews.setInt(R.id.layout_background, "setBackgroundResource", R.drawable.ic_rounded_corner_inactive)
+ }
+
+ for (appWidgetId in appWidgetIds) {
+ appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
+ }
+ }
+
+ /**
+ * This method is called when the BroadcastReceiver is receiving an Intent broadcast.
+ * It handles widget click actions and updates the widget background based on the V2Ray service state.
+ *
+ * @param context The Context in which the receiver is running.
+ * @param intent The Intent being received.
+ */
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+ if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
+ if (V2RayServiceManager.isRunning()) {
+ V2RayServiceManager.stopVService(context)
+ } else {
+ V2RayServiceManager.startVServiceFromToggle(context)
+ }
+ } else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
+ AppWidgetManager.getInstance(context)?.let { manager ->
+ when (intent.getIntExtra("key", 0)) {
+ AppConfig.MSG_STATE_RUNNING, AppConfig.MSG_STATE_START_SUCCESS -> {
+ updateWidgetBackground(
+ context, manager, manager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)),
+ true
+ )
+ }
+
+ AppConfig.MSG_STATE_NOT_RUNNING, AppConfig.MSG_STATE_START_FAILURE, AppConfig.MSG_STATE_STOP_SUCCESS -> {
+ updateWidgetBackground(
+ context, manager, manager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)),
+ false
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/NotificationService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/NotificationService.kt
new file mode 100644
index 00000000..92c551a6
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/NotificationService.kt
@@ -0,0 +1,252 @@
+package com.v2ray.ang.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.AppConfig.TAG_DIRECT
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.extension.toSpeedString
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.ui.MainActivity
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlin.math.min
+
+object NotificationService {
+ private const val NOTIFICATION_ID = 1
+ private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
+ private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
+ private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
+ private const val NOTIFICATION_ICON_THRESHOLD = 3000
+
+ private var lastQueryTime = 0L
+ private var mBuilder: NotificationCompat.Builder? = null
+ private var speedNotificationJob: Job? = null
+ private var mNotificationManager: NotificationManager? = null
+
+ /**
+ * Starts the speed notification.
+ * @param currentConfig The current profile configuration.
+ */
+ fun startSpeedNotification(currentConfig: ProfileItem?) {
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
+ if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
+
+ lastQueryTime = System.currentTimeMillis()
+ var lastZeroSpeed = false
+ val outboundTags = currentConfig?.getAllOutboundTags()
+ outboundTags?.remove(TAG_DIRECT)
+
+ speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
+ while (isActive) {
+ val queryTime = System.currentTimeMillis()
+ val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
+ var proxyTotal = 0L
+ val text = StringBuilder()
+ outboundTags?.forEach {
+ val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
+ val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
+ if (up + down > 0) {
+ appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
+ proxyTotal += up + down
+ }
+ }
+ val directUplink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.UPLINK)
+ val directDownlink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.DOWNLINK)
+ val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
+ if (!zeroSpeed || !lastZeroSpeed) {
+ if (proxyTotal == 0L) {
+ appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
+ }
+ appendSpeedString(
+ text, TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
+ directDownlink / sinceLastQueryInSeconds
+ )
+ updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
+ }
+ lastZeroSpeed = zeroSpeed
+ lastQueryTime = queryTime
+ delay(3000)
+ }
+ }
+ }
+
+ /**
+ * Shows the notification.
+ * @param currentConfig The current profile configuration.
+ */
+ fun showNotification(currentConfig: ProfileItem?) {
+ val service = getService() ?: return
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+
+ val startMainIntent = Intent(service, MainActivity::class.java)
+ val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
+
+ val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
+ stopV2RayIntent.`package` = ANG_PACKAGE
+ stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
+ val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
+
+ val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
+ restartV2RayIntent.`package` = ANG_PACKAGE
+ restartV2RayIntent.putExtra("key", AppConfig.MSG_STATE_RESTART)
+ val restartV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_RESTART_V2RAY, restartV2RayIntent, flags)
+
+ val channelId =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ createNotificationChannel()
+ } else {
+ // If earlier version channel ID is not used
+ // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
+ ""
+ }
+
+ mBuilder = NotificationCompat.Builder(service, channelId)
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setContentTitle(currentConfig?.remarks)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setOngoing(true)
+ .setShowWhen(false)
+ .setOnlyAlertOnce(true)
+ .setContentIntent(contentPendingIntent)
+ .addAction(
+ R.drawable.ic_delete_24dp,
+ service.getString(R.string.notification_action_stop_v2ray),
+ stopV2RayPendingIntent
+ )
+ .addAction(
+ R.drawable.ic_delete_24dp,
+ service.getString(R.string.title_service_restart),
+ restartV2RayPendingIntent
+ )
+
+ //mBuilder?.setDefaults(NotificationCompat.FLAG_ONLY_ALERT_ONCE)
+
+ service.startForeground(NOTIFICATION_ID, mBuilder?.build())
+ }
+
+ /**
+ * Cancels the notification.
+ */
+ fun cancelNotification() {
+ val service = getService() ?: return
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
+ } else {
+ service.stopForeground(true)
+ }
+
+ mBuilder = null
+ speedNotificationJob?.cancel()
+ speedNotificationJob = null
+ mNotificationManager = null
+ }
+
+ /**
+ * Stops the speed notification.
+ * @param currentConfig The current profile configuration.
+ */
+ fun stopSpeedNotification(currentConfig: ProfileItem?) {
+ speedNotificationJob?.let {
+ it.cancel()
+ speedNotificationJob = null
+ updateNotification(currentConfig?.remarks, 0, 0)
+ }
+ }
+
+ /**
+ * Creates a notification channel for Android O and above.
+ * @return The channel ID.
+ */
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun createNotificationChannel(): String {
+ val channelId = AppConfig.RAY_NG_CHANNEL_ID
+ val channelName = AppConfig.RAY_NG_CHANNEL_NAME
+ val chan = NotificationChannel(
+ channelId,
+ channelName, NotificationManager.IMPORTANCE_HIGH
+ )
+ chan.lightColor = Color.DKGRAY
+ chan.importance = NotificationManager.IMPORTANCE_NONE
+ chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ getNotificationManager()?.createNotificationChannel(chan)
+ return channelId
+ }
+
+ /**
+ * Updates the notification with the given content text and traffic data.
+ * @param contentText The content text.
+ * @param proxyTraffic The proxy traffic.
+ * @param directTraffic The direct traffic.
+ */
+ private fun updateNotification(contentText: String?, proxyTraffic: Long, directTraffic: Long) {
+ if (mBuilder != null) {
+ if (proxyTraffic < NOTIFICATION_ICON_THRESHOLD && directTraffic < NOTIFICATION_ICON_THRESHOLD) {
+ mBuilder?.setSmallIcon(R.drawable.ic_stat_name)
+ } else if (proxyTraffic > directTraffic) {
+ mBuilder?.setSmallIcon(R.drawable.ic_stat_proxy)
+ } else {
+ mBuilder?.setSmallIcon(R.drawable.ic_stat_direct)
+ }
+ mBuilder?.setStyle(NotificationCompat.BigTextStyle().bigText(contentText))
+ mBuilder?.setContentText(contentText)
+ getNotificationManager()?.notify(NOTIFICATION_ID, mBuilder?.build())
+ }
+ }
+
+ /**
+ * Gets the notification manager.
+ * @return The notification manager.
+ */
+ private fun getNotificationManager(): NotificationManager? {
+ if (mNotificationManager == null) {
+ val service = getService() ?: return null
+ mNotificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ }
+ return mNotificationManager
+ }
+
+ /**
+ * Appends the speed string to the given text.
+ * @param text The text to append to.
+ * @param name The name of the tag.
+ * @param up The uplink speed.
+ * @param down The downlink speed.
+ */
+ private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
+ var n = name ?: "no tag"
+ n = n.substring(0, min(n.length, 6))
+ text.append(n)
+ for (i in n.length..6 step 2) {
+ text.append("\t")
+ }
+ text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
+ }
+
+ /**
+ * Gets the service instance.
+ * @return The service instance.
+ */
+ private fun getService(): Service? {
+ return V2RayServiceManager.serviceControl?.get()?.getService()
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt
new file mode 100644
index 00000000..e81e3615
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/ProcessService.kt
@@ -0,0 +1,52 @@
+package com.v2ray.ang.service
+
+import android.content.Context
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class ProcessService {
+ private var process: Process? = null
+
+ /**
+ * Runs a process with the given command.
+ * @param context The context.
+ * @param cmd The command to run.
+ */
+ fun runProcess(context: Context, cmd: MutableList) {
+ Log.i(AppConfig.TAG, cmd.toString())
+
+ try {
+ val proBuilder = ProcessBuilder(cmd)
+ proBuilder.redirectErrorStream(true)
+ process = proBuilder
+ .directory(context.filesDir)
+ .start()
+
+ CoroutineScope(Dispatchers.IO).launch {
+ Thread.sleep(50L)
+ Log.i(AppConfig.TAG, "runProcess check")
+ process?.waitFor()
+ Log.i(AppConfig.TAG, "runProcess exited")
+ }
+ Log.i(AppConfig.TAG, process.toString())
+
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, e.toString(), e)
+ }
+ }
+
+ /**
+ * Stops the running process.
+ */
+ fun stopProcess() {
+ try {
+ Log.i(AppConfig.TAG, "runProcess destroy")
+ process?.destroy()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to destroy process", e)
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt
similarity index 58%
rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt
rename to V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt
index b7e07e91..7aecf634 100644
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/QSTileService.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/QSTileService.kt
@@ -1,6 +1,5 @@
package com.v2ray.ang.service
-import android.annotation.TargetApi
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -9,52 +8,80 @@ import android.graphics.drawable.Icon
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
-@TargetApi(Build.VERSION_CODES.N)
+@RequiresApi(Build.VERSION_CODES.N)
class QSTileService : TileService() {
+ /**
+ * Sets the state of the tile.
+ * @param state The state to set.
+ */
fun setState(state: Int) {
+ qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
if (state == Tile.STATE_INACTIVE) {
qsTile?.state = Tile.STATE_INACTIVE
qsTile?.label = getString(R.string.app_name)
- qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_v_idle)
} else if (state == Tile.STATE_ACTIVE) {
qsTile?.state = Tile.STATE_ACTIVE
- qsTile?.label = V2RayServiceManager.currentConfig?.remarks
- qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_v)
+ qsTile?.label = V2RayServiceManager.getRunningServerName()
}
qsTile?.updateTile()
}
+ /**
+ * Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
+ * `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
+ */
override fun onStartListening() {
super.onStartListening()
- setState(Tile.STATE_INACTIVE)
+
+ if (V2RayServiceManager.isRunning()) {
+ setState(Tile.STATE_ACTIVE)
+ } else {
+ setState(Tile.STATE_INACTIVE)
+ }
mMsgReceive = ReceiveMessageHandler(this)
- registerReceiver(mMsgReceive, IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY))
+ val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY)
+ ContextCompat.registerReceiver(applicationContext, mMsgReceive, mFilter, Utils.receiverFlags())
MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
}
+ /**
+ * Called when the tile stops listening.
+ */
override fun onStopListening() {
super.onStopListening()
- unregisterReceiver(mMsgReceive)
- mMsgReceive = null
+ try {
+ applicationContext.unregisterReceiver(mMsgReceive)
+ mMsgReceive = null
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to unregister receiver", e)
+ }
+
}
+ /**
+ * Called when the tile is clicked.
+ */
override fun onClick() {
super.onClick()
when (qsTile.state) {
Tile.STATE_INACTIVE -> {
- Utils.startVServiceFromToggle(this)
+ V2RayServiceManager.startVServiceFromToggle(this)
}
+
Tile.STATE_ACTIVE -> {
- Utils.stopVService(this)
+ V2RayServiceManager.stopVService(this)
}
}
}
@@ -62,22 +89,26 @@ class QSTileService : TileService() {
private var mMsgReceive: BroadcastReceiver? = null
private class ReceiveMessageHandler(context: QSTileService) : BroadcastReceiver() {
- internal var mReference: SoftReference = SoftReference(context)
+ var mReference: SoftReference = SoftReference(context)
override fun onReceive(ctx: Context?, intent: Intent?) {
val context = mReference.get()
when (intent?.getIntExtra("key", 0)) {
AppConfig.MSG_STATE_RUNNING -> {
context?.setState(Tile.STATE_ACTIVE)
}
+
AppConfig.MSG_STATE_NOT_RUNNING -> {
context?.setState(Tile.STATE_INACTIVE)
}
+
AppConfig.MSG_STATE_START_SUCCESS -> {
context?.setState(Tile.STATE_ACTIVE)
}
+
AppConfig.MSG_STATE_START_FAILURE -> {
context?.setState(Tile.STATE_INACTIVE)
}
+
AppConfig.MSG_STATE_STOP_SUCCESS -> {
context?.setState(Tile.STATE_INACTIVE)
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/ServiceControl.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/ServiceControl.kt
new file mode 100644
index 00000000..085c72eb
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/ServiceControl.kt
@@ -0,0 +1,28 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+
+interface ServiceControl {
+ /**
+ * Gets the service instance.
+ * @return The service instance.
+ */
+ fun getService(): Service
+
+ /**
+ * Starts the service.
+ */
+ fun startService()
+
+ /**
+ * Stops the service.
+ */
+ fun stopService()
+
+ /**
+ * Protects the VPN socket.
+ * @param socket The socket to protect.
+ * @return True if the socket is protected, false otherwise.
+ */
+ fun vpnProtect(socket: Int): Boolean
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdater.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdater.kt
new file mode 100644
index 00000000..5f3f8172
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdater.kt
@@ -0,0 +1,67 @@
+package com.v2ray.ang.service
+
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.SUBSCRIPTION_UPDATE_CHANNEL
+import com.v2ray.ang.AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME
+import com.v2ray.ang.R
+import com.v2ray.ang.handler.AngConfigManager.updateConfigViaSub
+import com.v2ray.ang.handler.MmkvManager
+
+object SubscriptionUpdater {
+
+ class UpdateTask(context: Context, params: WorkerParameters) :
+ CoroutineWorker(context, params) {
+
+ private val notificationManager = NotificationManagerCompat.from(applicationContext)
+ private val notification =
+ NotificationCompat.Builder(applicationContext, SUBSCRIPTION_UPDATE_CHANNEL)
+ .setWhen(0)
+ .setTicker("Update")
+ .setContentTitle(context.getString(R.string.title_pref_auto_update_subscription))
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+
+ /**
+ * Performs the subscription update work.
+ * @return The result of the work.
+ */
+ @SuppressLint("MissingPermission")
+ override suspend fun doWork(): Result {
+ Log.i(AppConfig.TAG, "subscription automatic update starting")
+
+ val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate }
+
+ for (sub in subs) {
+ val subItem = sub.second
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ notification.setChannelId(SUBSCRIPTION_UPDATE_CHANNEL)
+ val channel =
+ NotificationChannel(
+ SUBSCRIPTION_UPDATE_CHANNEL,
+ SUBSCRIPTION_UPDATE_CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_MIN
+ )
+ notificationManager.createNotificationChannel(channel)
+ }
+ notificationManager.notify(3, notification.build())
+ Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
+ updateConfigViaSub(Pair(sub.first, subItem))
+ notification.setContentText("Updating ${subItem.remarks}")
+ }
+ notificationManager.cancel(3)
+ return Result.success()
+ }
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt
new file mode 100644
index 00000000..25fcd1a6
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayProxyOnlyService.kt
@@ -0,0 +1,93 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.IBinder
+import androidx.annotation.RequiresApi
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.MyContextWrapper
+import java.lang.ref.SoftReference
+
+class V2RayProxyOnlyService : Service(), ServiceControl {
+ /**
+ * Initializes the service.
+ */
+ override fun onCreate() {
+ super.onCreate()
+ V2RayServiceManager.serviceControl = SoftReference(this)
+ }
+
+ /**
+ * Handles the start command for the service.
+ * @param intent The intent.
+ * @param flags The flags.
+ * @param startId The start ID.
+ * @return The start mode.
+ */
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ V2RayServiceManager.startCoreLoop()
+ return START_STICKY
+ }
+
+ /**
+ * Destroys the service.
+ */
+ override fun onDestroy() {
+ super.onDestroy()
+ V2RayServiceManager.stopCoreLoop()
+ }
+
+ /**
+ * Gets the service instance.
+ * @return The service instance.
+ */
+ override fun getService(): Service {
+ return this
+ }
+
+ /**
+ * Starts the service.
+ */
+ override fun startService() {
+ // do nothing
+ }
+
+ /**
+ * Stops the service.
+ */
+ override fun stopService() {
+ stopSelf()
+ }
+
+ /**
+ * Protects the VPN socket.
+ * @param socket The socket to protect.
+ * @return True if the socket is protected, false otherwise.
+ */
+ override fun vpnProtect(socket: Int): Boolean {
+ return true
+ }
+
+ /**
+ * Binds the service.
+ * @param intent The intent.
+ * @return The binder.
+ */
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ /**
+ * Attaches the base context to the service.
+ * @param newBase The new base context.
+ */
+ @RequiresApi(Build.VERSION_CODES.N)
+ override fun attachBaseContext(newBase: Context?) {
+ val context = newBase?.let {
+ MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
+ }
+ super.attachBaseContext(context)
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayServiceManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayServiceManager.kt
new file mode 100644
index 00000000..4f42ca23
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayServiceManager.kt
@@ -0,0 +1,377 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.util.Log
+import androidx.core.content.ContextCompat
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.handler.SpeedtestManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.PluginUtil
+import com.v2ray.ang.util.Utils
+import go.Seq
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import libv2ray.CoreCallbackHandler
+import libv2ray.CoreController
+import libv2ray.Libv2ray
+import java.lang.ref.SoftReference
+
+object V2RayServiceManager {
+
+ private val coreController: CoreController = Libv2ray.newCoreController(CoreCallback())
+ private val mMsgReceive = ReceiveMessageHandler()
+ private var currentConfig: ProfileItem? = null
+
+ var serviceControl: SoftReference? = null
+ set(value) {
+ field = value
+ Seq.setContext(value?.get()?.getService()?.applicationContext)
+ Libv2ray.initCoreEnv(Utils.userAssetPath(value?.get()?.getService()), Utils.getDeviceIdForXUDPBaseKey())
+ }
+
+ /**
+ * Starts the V2Ray service from a toggle action.
+ * @param context The context from which the service is started.
+ * @return True if the service was started successfully, false otherwise.
+ */
+ fun startVServiceFromToggle(context: Context): Boolean {
+ if (MmkvManager.getSelectServer().isNullOrEmpty()) {
+ context.toast(R.string.app_tile_first_use)
+ return false
+ }
+ startContextService(context)
+ return true
+ }
+
+ /**
+ * Starts the V2Ray service.
+ * @param context The context from which the service is started.
+ * @param guid The GUID of the server configuration to use (optional).
+ */
+ fun startVService(context: Context, guid: String? = null) {
+ if (guid != null) {
+ MmkvManager.setSelectServer(guid)
+ }
+ startContextService(context)
+ }
+
+ /**
+ * Stops the V2Ray service.
+ * @param context The context from which the service is stopped.
+ */
+ fun stopVService(context: Context) {
+ context.toast(R.string.toast_services_stop)
+ MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
+ }
+
+ /**
+ * Checks if the V2Ray service is running.
+ * @return True if the service is running, false otherwise.
+ */
+ fun isRunning() = coreController.isRunning
+
+ /**
+ * Gets the name of the currently running server.
+ * @return The name of the running server.
+ */
+ fun getRunningServerName() = currentConfig?.remarks.orEmpty()
+
+ /**
+ * Starts the context service for V2Ray.
+ * Chooses between VPN service or Proxy-only service based on user settings.
+ * @param context The context from which the service is started.
+ */
+ private fun startContextService(context: Context) {
+ if (coreController.isRunning) {
+ return
+ }
+ val guid = MmkvManager.getSelectServer() ?: return
+ val config = MmkvManager.decodeServerConfig(guid) ?: return
+ if (config.configType != EConfigType.CUSTOM
+ && !Utils.isValidUrl(config.server)
+ && !Utils.isPureIpAddress(config.server.orEmpty())
+ ) return
+// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
+// if (!result.status) return
+
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) == true) {
+ context.toast(R.string.toast_warning_pref_proxysharing_short)
+ } else {
+ context.toast(R.string.toast_services_start)
+ }
+ val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: AppConfig.VPN) == AppConfig.VPN) {
+ Intent(context.applicationContext, V2RayVpnService::class.java)
+ } else {
+ Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
+ }
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ /**
+ * Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
+ * `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
+ * Starts the V2Ray core service.
+ */
+ fun startCoreLoop(): Boolean {
+ if (coreController.isRunning) {
+ return false
+ }
+
+ val service = getService() ?: return false
+ val guid = MmkvManager.getSelectServer() ?: return false
+ val config = MmkvManager.decodeServerConfig(guid) ?: return false
+ val result = V2rayConfigManager.getV2rayConfig(service, guid)
+ if (!result.status)
+ return false
+
+ try {
+ val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
+ mFilter.addAction(Intent.ACTION_SCREEN_ON)
+ mFilter.addAction(Intent.ACTION_SCREEN_OFF)
+ mFilter.addAction(Intent.ACTION_USER_PRESENT)
+ ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
+ return false
+ }
+
+ currentConfig = config
+
+ try {
+ coreController.startLoop(result.content)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to start Core loop", e)
+ return false
+ }
+
+ if (coreController.isRunning == false) {
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
+ NotificationService.cancelNotification()
+ return false
+ }
+
+ try {
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
+ NotificationService.showNotification(currentConfig)
+ NotificationService.startSpeedNotification(currentConfig)
+
+ PluginUtil.runPlugin(service, config, result.socksPort)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to startup service", e)
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Stops the V2Ray core service.
+ * Unregisters broadcast receivers, stops notifications, and shuts down plugins.
+ * @return True if the core was stopped successfully, false otherwise.
+ */
+ fun stopCoreLoop(): Boolean {
+ val service = getService() ?: return false
+
+ if (coreController.isRunning) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ coreController.stopLoop()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
+ }
+ }
+ }
+
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
+ NotificationService.cancelNotification()
+
+ try {
+ service.unregisterReceiver(mMsgReceive)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
+ }
+ PluginUtil.stopPlugin()
+
+ return true
+ }
+
+ /**
+ * Queries the statistics for a given tag and link.
+ * @param tag The tag to query.
+ * @param link The link to query.
+ * @return The statistics value.
+ */
+ fun queryStats(tag: String, link: String): Long {
+ return coreController.queryStats(tag, link)
+ }
+
+ /**
+ * Measures the connection delay for the current V2Ray configuration.
+ * Tests with primary URL first, then falls back to alternative URL if needed.
+ * Also fetches remote IP information if the delay test was successful.
+ */
+ private fun measureV2rayDelay() {
+ if (coreController.isRunning == false) {
+ return
+ }
+
+ CoroutineScope(Dispatchers.IO).launch {
+ val service = getService() ?: return@launch
+ var time = -1L
+ var errorStr = ""
+
+ try {
+ time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
+ errorStr = e.message?.substringAfter("\":") ?: "empty message"
+ }
+ if (time == -1L) {
+ try {
+ time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e)
+ errorStr = e.message?.substringAfter("\":") ?: "empty message"
+ }
+ }
+
+ val result = if (time >= 0) {
+ service.getString(R.string.connection_test_available, time)
+ } else {
+ service.getString(R.string.connection_test_error, errorStr)
+ }
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
+
+ // Only fetch IP info if the delay test was successful
+ if (time >= 0) {
+ SpeedtestManager.getRemoteIPInfo()?.let { ip ->
+ MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
+ }
+ }
+ }
+ }
+
+ /**
+ * Gets the current service instance.
+ * @return The current service instance, or null if not available.
+ */
+ private fun getService(): Service? {
+ return serviceControl?.get()?.getService()
+ }
+
+ /**
+ * Core callback handler implementation for handling V2Ray core events.
+ * Handles startup, shutdown, socket protection, and status emission.
+ */
+ private class CoreCallback : CoreCallbackHandler {
+ /**
+ * Called when V2Ray core starts up.
+ * @return 0 for success, any other value for failure.
+ */
+ override fun startup(): Long {
+ return 0
+ }
+
+ /**
+ * Called when V2Ray core shuts down.
+ * @return 0 for success, any other value for failure.
+ */
+ override fun shutdown(): Long {
+ val serviceControl = serviceControl?.get() ?: return -1
+ return try {
+ serviceControl.stopService()
+ 0
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
+ -1
+ }
+ }
+
+ /**
+ * Called when V2Ray core emits status information.
+ * @param l Status code.
+ * @param s Status message.
+ * @return Always returns 0.
+ */
+ override fun onEmitStatus(l: Long, s: String?): Long {
+ return 0
+ }
+ }
+
+ /**
+ * Broadcast receiver for handling messages sent to the service.
+ * Handles registration, service control, and screen events.
+ */
+ private class ReceiveMessageHandler : BroadcastReceiver() {
+ /**
+ * Handles received broadcast messages.
+ * Processes service control messages and screen state changes.
+ * @param ctx The context in which the receiver is running.
+ * @param intent The intent being received.
+ */
+ override fun onReceive(ctx: Context?, intent: Intent?) {
+ val serviceControl = serviceControl?.get() ?: return
+ when (intent?.getIntExtra("key", 0)) {
+ AppConfig.MSG_REGISTER_CLIENT -> {
+ if (coreController.isRunning) {
+ MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
+ } else {
+ MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
+ }
+ }
+
+ AppConfig.MSG_UNREGISTER_CLIENT -> {
+ // nothing to do
+ }
+
+ AppConfig.MSG_STATE_START -> {
+ // nothing to do
+ }
+
+ AppConfig.MSG_STATE_STOP -> {
+ Log.i(AppConfig.TAG, "Stop Service")
+ serviceControl.stopService()
+ }
+
+ AppConfig.MSG_STATE_RESTART -> {
+ Log.i(AppConfig.TAG, "Restart Service")
+ serviceControl.stopService()
+ Thread.sleep(500L)
+ startVService(serviceControl.getService())
+ }
+
+ AppConfig.MSG_MEASURE_DELAY -> {
+ measureV2rayDelay()
+ }
+ }
+
+ when (intent?.action) {
+ Intent.ACTION_SCREEN_OFF -> {
+ Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
+ NotificationService.stopSpeedNotification(currentConfig)
+ }
+
+ Intent.ACTION_SCREEN_ON -> {
+ Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
+ NotificationService.startSpeedNotification(currentConfig)
+ }
+ }
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayTestService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayTestService.kt
new file mode 100644
index 00000000..3fef1ae1
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayTestService.kt
@@ -0,0 +1,91 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+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_SUCCESS
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.extension.serializable
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SpeedtestManager
+import com.v2ray.ang.handler.V2rayConfigManager
+import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.PluginUtil
+import com.v2ray.ang.util.Utils
+import go.Seq
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import libv2ray.Libv2ray
+import java.util.concurrent.Executors
+
+class V2RayTestService : Service() {
+ private val realTestScope by lazy { CoroutineScope(Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()).asCoroutineDispatcher()) }
+
+ /**
+ * Initializes the V2Ray environment.
+ */
+ override fun onCreate() {
+ super.onCreate()
+ Seq.setContext(this)
+ Libv2ray.initCoreEnv(Utils.userAssetPath(this), Utils.getDeviceIdForXUDPBaseKey())
+ }
+
+ /**
+ * Handles the start command for the service.
+ * @param intent The intent.
+ * @param flags The flags.
+ * @param startId The start ID.
+ * @return The start mode.
+ */
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.getIntExtra("key", 0)) {
+ MSG_MEASURE_CONFIG -> {
+ val guid = intent.serializable("content") ?: ""
+ realTestScope.launch {
+ val result = startRealPing(guid)
+ MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
+ }
+ }
+
+ MSG_MEASURE_CONFIG_CANCEL -> {
+ realTestScope.coroutineContext[Job]?.cancelChildren()
+ }
+ }
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ /**
+ * Binds the service.
+ * @param intent The intent.
+ * @return The binder.
+ */
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ /**
+ * 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 = PluginUtil.realPingHy2(this, config)
+ return delay
+ } else {
+ val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(this, guid)
+ if (!configResult.status) {
+ return retFailure
+ }
+ return SpeedtestManager.realPing(configResult.content)
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt
new file mode 100644
index 00000000..d734c299
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/V2RayVpnService.kt
@@ -0,0 +1,375 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.ProxyInfo
+import android.net.VpnService
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.os.StrictMode
+import android.util.Log
+import androidx.annotation.RequiresApi
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.LOOPBACK
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.MyContextWrapper
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.io.File
+import java.lang.ref.SoftReference
+
+class V2RayVpnService : VpnService(), ServiceControl {
+ companion object {
+ private const val VPN_MTU = 1500
+ private const val TUN2SOCKS = "libtun2socks.so"
+ }
+
+ private lateinit var mInterface: ParcelFileDescriptor
+ private var isRunning = false
+ private lateinit var process: Process
+
+ /**destroy
+ * Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
+ *
+ * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
+ * satisfies default network capabilities but only THE default network. Unfortunately we need to have
+ * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
+ *
+ * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
+ */
+ @delegate:RequiresApi(Build.VERSION_CODES.P)
+ private val defaultNetworkRequest by lazy {
+ NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
+ .build()
+ }
+
+ private val connectivity by lazy { getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager }
+
+ @delegate:RequiresApi(Build.VERSION_CODES.P)
+ private val defaultNetworkCallback by lazy {
+ object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ setUnderlyingNetworks(arrayOf(network))
+ }
+
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
+ // it's a good idea to refresh capabilities
+ setUnderlyingNetworks(arrayOf(network))
+ }
+
+ override fun onLost(network: Network) {
+ setUnderlyingNetworks(null)
+ }
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
+ StrictMode.setThreadPolicy(policy)
+ V2RayServiceManager.serviceControl = SoftReference(this)
+ }
+
+ override fun onRevoke() {
+ stopV2Ray()
+ }
+
+// override fun onLowMemory() {
+// stopV2Ray()
+// super.onLowMemory()
+// }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ NotificationService.cancelNotification()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (V2RayServiceManager.startCoreLoop()) {
+ startService()
+ }
+ return START_STICKY
+ //return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun getService(): Service {
+ return this
+ }
+
+ override fun startService() {
+ setup()
+ }
+
+ override fun stopService() {
+ stopV2Ray(true)
+ }
+
+ override fun vpnProtect(socket: Int): Boolean {
+ return protect(socket)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ override fun attachBaseContext(newBase: Context?) {
+ val context = newBase?.let {
+ MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
+ }
+ super.attachBaseContext(context)
+ }
+
+ /**
+ * Sets up the VPN service.
+ * Prepares the VPN and configures it if preparation is successful.
+ */
+ private fun setup() {
+ val prepare = prepare(this)
+ if (prepare != null) {
+ return
+ }
+
+ if (setupVpnService() != true) {
+ return
+ }
+
+ runTun2socks()
+ }
+
+ /**
+ * Configures the VPN service.
+ * @return True if the VPN service was configured successfully, false otherwise.
+ */
+ private fun setupVpnService(): Boolean {
+ // If the old interface has exactly the same parameters, use it!
+ // Configure a builder while parsing the parameters.
+ val builder = Builder()
+ val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
+ //val enableLocalDns = defaultDPreference.getPrefBoolean(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
+
+ builder.setMtu(VPN_MTU)
+ builder.addAddress(vpnConfig.ipv4Client, 30)
+ //builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
+ val bypassLan = SettingsManager.routingRulesetsBypassLan()
+ if (bypassLan) {
+ AppConfig.ROUTED_IP_LIST.forEach {
+ val addr = it.split('/')
+ builder.addRoute(addr[0], addr[1].toInt())
+ }
+ } else {
+ builder.addRoute("0.0.0.0", 0)
+ }
+
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
+ builder.addAddress(vpnConfig.ipv6Client, 126)
+ if (bypassLan) {
+ builder.addRoute("2000::", 3) //currently only 1/8 of total ipV6 is in use
+ builder.addRoute("fc00::", 18) //Xray-core default FakeIPv6 Pool
+ } else {
+ builder.addRoute("::", 0)
+ }
+ }
+
+// if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
+// builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
+// } else {
+ SettingsManager.getVpnDnsServers()
+ .forEach {
+ if (Utils.isPureIpAddress(it)) {
+ builder.addDnsServer(it)
+ }
+ }
+// }
+
+ builder.setSession(V2RayServiceManager.getRunningServerName())
+
+ val selfPackageName = BuildConfig.APPLICATION_ID
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)) {
+ val apps = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
+ val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
+ //process self package
+ if (bypassApps) apps?.add(selfPackageName) else apps?.remove(selfPackageName)
+ apps?.forEach {
+ try {
+ if (bypassApps)
+ builder.addDisallowedApplication(it)
+ else
+ builder.addAllowedApplication(it)
+ } catch (e: PackageManager.NameNotFoundException) {
+ Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e)
+ }
+ }
+ } else {
+ builder.addDisallowedApplication(selfPackageName)
+ }
+
+ // Close the old interface since the parameters have been changed.
+ try {
+ mInterface.close()
+ } catch (ignored: Exception) {
+ // ignored
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ try {
+ connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to request default network", e)
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ builder.setMetered(false)
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY)) {
+ builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOOPBACK, SettingsManager.getHttpPort()))
+ }
+ }
+
+ // Create a new interface using the builder and save the parameters.
+ try {
+ mInterface = builder.establish()!!
+ isRunning = true
+ return true
+ } catch (e: Exception) {
+ // non-nullable lateinit var
+ Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
+ stopV2Ray()
+ }
+ return false
+ }
+
+ /**
+ * Runs the tun2socks process.
+ * Starts the tun2socks process with the appropriate parameters.
+ */
+ private fun runTun2socks() {
+ Log.i(AppConfig.TAG, "Start run $TUN2SOCKS")
+ val socksPort = SettingsManager.getSocksPort()
+ val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
+ val cmd = arrayListOf(
+ File(applicationContext.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath,
+ "--netif-ipaddr", vpnConfig.ipv4Router,
+ "--netif-netmask", "255.255.255.252",
+ "--socks-server-addr", "$LOOPBACK:${socksPort}",
+ "--tunmtu", VPN_MTU.toString(),
+ "--sock-path", "sock_path",//File(applicationContext.filesDir, "sock_path").absolutePath,
+ "--enable-udprelay",
+ "--loglevel", "notice"
+ )
+
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
+ cmd.add("--netif-ip6addr")
+ cmd.add(vpnConfig.ipv6Router)
+ }
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
+ val localDnsPort = Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT), AppConfig.PORT_LOCAL_DNS.toInt())
+ cmd.add("--dnsgw")
+ cmd.add("$LOOPBACK:${localDnsPort}")
+ }
+ Log.i(AppConfig.TAG, cmd.toString())
+
+ try {
+ val proBuilder = ProcessBuilder(cmd)
+ proBuilder.redirectErrorStream(true)
+ process = proBuilder
+ .directory(applicationContext.filesDir)
+ .start()
+ Thread {
+ Log.i(AppConfig.TAG, "$TUN2SOCKS check")
+ process.waitFor()
+ Log.i(AppConfig.TAG, "$TUN2SOCKS exited")
+ if (isRunning) {
+ Log.i(AppConfig.TAG, "$TUN2SOCKS restart")
+ runTun2socks()
+ }
+ }.start()
+ Log.i(AppConfig.TAG, "$TUN2SOCKS process info : ${process.toString()}")
+
+ sendFd()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to start $TUN2SOCKS process", e)
+ }
+ }
+
+ /**
+ * Sends the file descriptor to the tun2socks process.
+ * Attempts to send the file descriptor multiple times if necessary.
+ */
+ private fun sendFd() {
+ val fd = mInterface.fileDescriptor
+ val path = File(applicationContext.filesDir, "sock_path").absolutePath
+ Log.i(AppConfig.TAG, "LocalSocket path : $path")
+
+ CoroutineScope(Dispatchers.IO).launch {
+ var tries = 0
+ while (true) try {
+ Thread.sleep(50L shl tries)
+ Log.i(AppConfig.TAG, "LocalSocket sendFd tries: $tries")
+ LocalSocket().use { localSocket ->
+ localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM))
+ localSocket.setFileDescriptorsForSend(arrayOf(fd))
+ localSocket.outputStream.write(42)
+ }
+ break
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to send file descriptor, try: $tries", e)
+ if (tries > 5) break
+ tries += 1
+ }
+ }
+ }
+
+ /**
+ * 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 emptyInfo = VpnNetworkInfo()
+// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
+// saveVpnNetworkInfo(configName, info)
+ isRunning = false
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ try {
+ connectivity.unregisterNetworkCallback(defaultNetworkCallback)
+ } catch (ignored: Exception) {
+ // ignored
+ }
+ }
+
+ try {
+ Log.i(AppConfig.TAG, "$TUN2SOCKS destroy")
+ process.destroy()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to destroy $TUN2SOCKS process", e)
+ }
+
+ V2RayServiceManager.stopCoreLoop()
+
+ if (isForced) {
+ //stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
+ //It's strage but true.
+ //This can be verified by putting stopself() behind and call stopLoop and startLoop
+ //in a row for several times. You will find that later created v2ray core report port in use
+ //which means the first v2ray core somehow failed to stop and release the port.
+ stopSelf()
+
+ try {
+ mInterface.close()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
+ }
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt
new file mode 100644
index 00000000..1931cb45
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt
@@ -0,0 +1,201 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.content.Intent
+import android.os.Build
+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 com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityAboutBinding
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SpeedtestManager
+import com.v2ray.ang.util.Utils
+import com.v2ray.ang.util.ZipUtil
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class AboutActivity : BaseActivity() {
+
+ private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
+ private val extDir by lazy { File(Utils.backupPath(this)) }
+
+ 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)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+
+ title = getString(R.string.title_about)
+
+ binding.tvBackupSummary.text = this.getString(R.string.summary_configuration_backup, extDir)
+
+ binding.layoutBackup.setOnClickListener {
+ val ret = backupConfiguration(extDir.absolutePath)
+ if (ret.first) {
+ toastSuccess(R.string.toast_success)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ }
+
+ binding.layoutShare.setOnClickListener {
+ val ret = backupConfiguration(cacheDir.absolutePath)
+ if (ret.first) {
+ startActivity(
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).setType("application/zip")
+ .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .putExtra(
+ Intent.EXTRA_STREAM,
+ FileProvider.getUriForFile(
+ this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
+ )
+ ), getString(R.string.title_configuration_share)
+ )
+ )
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ }
+
+ binding.layoutRestore.setOnClickListener {
+ val permission =
+ 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) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ try {
+ showFileChooser()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to show file chooser", e)
+ }
+ } else {
+ requestPermissionLauncher.launch(permission)
+ }
+ }
+
+ binding.layoutSoureCcode.setOnClickListener {
+ Utils.openUri(this, AppConfig.APP_URL)
+ }
+
+ binding.layoutFeedback.setOnClickListener {
+ Utils.openUri(this, AppConfig.APP_ISSUES_URL)
+ }
+
+ binding.layoutOssLicenses.setOnClickListener {
+ val webView = android.webkit.WebView(this)
+ webView.loadUrl("file:///android_asset/open_source_licenses.html")
+ android.app.AlertDialog.Builder(this)
+ .setTitle("Open source licenses")
+ .setView(webView)
+ .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
+ .show()
+ }
+
+ binding.layoutTgChannel.setOnClickListener {
+ Utils.openUri(this, AppConfig.TG_CHANNEL_URL)
+ }
+
+ binding.layoutPrivacyPolicy.setOnClickListener {
+ Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
+ }
+
+ "v${BuildConfig.VERSION_NAME} (${SpeedtestManager.getLibVersion()})".also {
+ binding.tvVersion.text = it
+ }
+ }
+
+ private fun backupConfiguration(outputZipFilePos: String): Pair {
+ val dateFormated = SimpleDateFormat(
+ "yyyy-MM-dd-HH-mm-ss",
+ Locale.getDefault()
+ ).format(System.currentTimeMillis())
+ val folderName = "${getString(R.string.app_name)}_${dateFormated}"
+ val backupDir = this.cacheDir.absolutePath + "/$folderName"
+ val outputZipFilePath = "$outputZipFilePos/$folderName.zip"
+
+ val count = MMKV.backupAllToDirectory(backupDir)
+ if (count <= 0) {
+ return Pair(false, "")
+ }
+
+ if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
+ return Pair(true, outputZipFilePath)
+ } else {
+ return Pair(false, "")
+ }
+ }
+
+ private fun restoreConfiguration(zipFile: File): Boolean {
+ val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
+
+ if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
+ return false
+ }
+
+ val count = MMKV.restoreAllFromDirectory(backupDir)
+ return count > 0
+ }
+
+ private fun showFileChooser() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+ type = "*/*"
+ addCategory(Intent.CATEGORY_OPENABLE)
+ }
+
+ try {
+ chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
+ } catch (ex: android.content.ActivityNotFoundException) {
+ Log.e(AppConfig.TAG, "File chooser activity not found", ex)
+ toast(R.string.toast_require_file_manager)
+ }
+ }
+
+ 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)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Error during file restore", e)
+ toastError(R.string.toast_failure)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt
new file mode 100644
index 00000000..0cd7f647
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt
@@ -0,0 +1,65 @@
+package com.v2ray.ang.ui
+
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.view.MenuItem
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import androidx.recyclerview.widget.DividerItemDecoration
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.helper.CustomDividerItemDecoration
+import com.v2ray.ang.util.MyContextWrapper
+import com.v2ray.ang.util.Utils
+
+
+abstract class BaseActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ if (!Utils.getDarkModeStatus(this)) {
+ WindowCompat.getInsetsController(window, window.decorView).apply {
+ isAppearanceLightStatusBars = true
+ }
+ }
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ android.R.id.home -> {
+ // Handles the home button press by delegating to the onBackPressedDispatcher.
+ // This ensures consistent back navigation behavior.
+ onBackPressedDispatcher.onBackPressed()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ override fun attachBaseContext(newBase: Context?) {
+ super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
+ }
+
+ /**
+ * Adds a custom divider to a RecyclerView.
+ *
+ * @param recyclerView The target RecyclerView to which the divider will be added.
+ * @param context The context used to access resources.
+ * @param drawableResId The resource ID of the drawable to be used as the divider.
+ * @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
+ */
+ fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, context: Context?, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
+ // Get the drawable from resources
+ val drawable = ContextCompat.getDrawable(context!!, drawableResId)
+ requireNotNull(drawable) { "Drawable resource not found" }
+
+ // Create a DividerItemDecoration with the specified orientation
+ val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
+
+ // Add the divider to the RecyclerView
+ recyclerView.addItemDecoration(dividerItemDecoration)
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt
new file mode 100644
index 00000000..a9b698c5
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt
@@ -0,0 +1,77 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
+import com.v2ray.ang.dto.CheckUpdateResult
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SpeedtestManager
+import com.v2ray.ang.handler.UpdateCheckerManager
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.launch
+
+class CheckUpdateActivity : BaseActivity() {
+
+ private val binding by lazy { ActivityCheckUpdateBinding.inflate(layoutInflater) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+
+ title = getString(R.string.update_check_for_update)
+
+ binding.layoutCheckUpdate.setOnClickListener {
+ checkForUpdates(binding.checkPreRelease.isChecked)
+ }
+
+ binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
+ MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
+ }
+ binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
+
+ "v${BuildConfig.VERSION_NAME} (${SpeedtestManager.getLibVersion()})".also {
+ binding.tvVersion.text = it
+ }
+
+ checkForUpdates(binding.checkPreRelease.isChecked)
+ }
+
+ private fun checkForUpdates(includePreRelease: Boolean) {
+ toast(R.string.update_checking_for_update)
+
+ lifecycleScope.launch {
+ try {
+ val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
+ if (result.hasUpdate) {
+ showUpdateDialog(result)
+ } else {
+ toastSuccess(R.string.update_already_latest_version)
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
+ toastError(e.message ?: getString(R.string.toast_failure))
+ }
+ }
+ }
+
+ private fun showUpdateDialog(result: CheckUpdateResult) {
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.update_new_version_found, result.latestVersion))
+ .setMessage(result.releaseNotes)
+ .setPositiveButton(R.string.update_now) { _, _ ->
+ result.downloadUrl?.let {
+ Utils.openUri(this, it)
+ }
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/FragmentAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/FragmentAdapter.kt
similarity index 90%
rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/FragmentAdapter.kt
rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/FragmentAdapter.kt
index d9d12992..deeb43c3 100644
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/FragmentAdapter.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/FragmentAdapter.kt
@@ -5,7 +5,7 @@ import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
class FragmentAdapter(fragmentActivity: FragmentActivity, private val mFragments: List) :
- FragmentStateAdapter(fragmentActivity) {
+ FragmentStateAdapter(fragmentActivity) {
override fun createFragment(position: Int): Fragment {
return mFragments[position]
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt
new file mode 100644
index 00000000..436f5913
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt
@@ -0,0 +1,156 @@
+package com.v2ray.ang.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.widget.SearchView
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+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.databinding.ActivityLogcatBinding
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.IOException
+
+
+class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
+ private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
+
+ private var logsetsAll: MutableList = mutableListOf()
+ var logsets: MutableList = mutableListOf()
+ private val adapter by lazy { LogcatRecyclerAdapter(this) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+
+ title = getString(R.string.title_logcat)
+
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+ binding.recyclerView.adapter = adapter
+
+ binding.refreshLayout.setOnRefreshListener(this)
+
+ logsets.add(getString(R.string.pull_down_to_refresh))
+ }
+
+ private fun getLogcat() {
+
+ try {
+ binding.refreshLayout.isRefreshing = true
+
+ lifecycleScope.launch(Dispatchers.Default) {
+ val lst = LinkedHashSet()
+ lst.add("logcat")
+ lst.add("-d")
+ lst.add("-v")
+ lst.add("time")
+ lst.add("-s")
+ lst.add("GoLog,tun2socks,${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()
+ 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 {
+ menuInflater.inflate(R.menu.menu_logcat, menu)
+
+ val searchItem = menu.findItem(R.id.search_view)
+ if (searchItem != null) {
+ val searchView = searchItem.actionView as SearchView
+ searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String?): Boolean = false
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ filterLogs(newText)
+ return false
+ }
+ })
+ searchView.setOnCloseListener {
+ filterLogs("")
+ false
+ }
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.copy_all -> {
+ Utils.setClipboard(this, logsets.joinToString("\n"))
+ toastSuccess(R.string.toast_success)
+ true
+ }
+
+ R.id.clear_all -> {
+ clearLogcat()
+ true
+ }
+
+ 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() {
+ getLogcat()
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun refreshData() {
+ adapter.notifyDataSetChanged()
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt
new file mode 100644
index 00000000..e83714d5
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt
@@ -0,0 +1,44 @@
+package com.v2ray.ang.ui
+
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
+
+class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter() {
+ private var mActivity: LogcatActivity = activity
+
+
+ override fun getItemCount() = mActivity.logsets.size
+
+ override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
+ try {
+ val log = mActivity.logsets[position]
+ if (log.isEmpty()) {
+ holder.itemSubSettingBinding.logTag.text = ""
+ holder.itemSubSettingBinding.logContent.text = ""
+ } else {
+ val content = log.split("):", limit = 2)
+ holder.itemSubSettingBinding.logTag.text = content.first().split("(", limit = 2).first().trim()
+ holder.itemSubSettingBinding.logContent.text = if (content.count() > 1) content.last().trim() else ""
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Error binding log view data", e)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
+ return MainViewHolder(
+ ItemRecyclerLogcatBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ class MainViewHolder(val itemSubSettingBinding: ItemRecyclerLogcatBinding) : RecyclerView.ViewHolder(itemSubSettingBinding.root)
+
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt
new file mode 100644
index 00000000..0c7584d8
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt
@@ -0,0 +1,695 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.res.ColorStateList
+import android.net.Uri
+import android.net.VpnService
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.KeyEvent
+import android.view.Menu
+import android.view.MenuItem
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.ActionBarDrawerToggle
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.widget.SearchView
+import androidx.core.content.ContextCompat
+import androidx.core.view.GravityCompat
+import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.ItemTouchHelper
+import com.google.android.material.navigation.NavigationView
+import com.google.android.material.tabs.TabLayout
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.VPN
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityMainBinding
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.handler.AngConfigManager
+import com.v2ray.ang.handler.MigrateManager
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
+import com.v2ray.ang.service.V2RayServiceManager
+import com.v2ray.ang.util.Utils
+import com.v2ray.ang.viewmodel.MainViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener {
+ private val binding by lazy {
+ ActivityMainBinding.inflate(layoutInflater)
+ }
+
+ private val adapter by lazy { MainRecyclerAdapter(this) }
+ private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ startV2Ray()
+ }
+ }
+ private val requestSubSettingActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ initGroupTab()
+ }
+ private val tabGroupListener = object : TabLayout.OnTabSelectedListener {
+ override fun onTabSelected(tab: TabLayout.Tab?) {
+ val selectId = tab?.tag.toString()
+ if (selectId != mainViewModel.subscriptionId) {
+ mainViewModel.subscriptionIdChanged(selectId)
+ }
+ }
+
+ override fun onTabUnselected(tab: TabLayout.Tab?) {
+ }
+
+ override fun onTabReselected(tab: TabLayout.Tab?) {
+ }
+ }
+ private var mItemTouchHelper: ItemTouchHelper? = null
+ val mainViewModel: MainViewModel by viewModels()
+
+ // 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?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ title = getString(R.string.title_server)
+ setSupportActionBar(binding.toolbar)
+
+ 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)
+ }
+ }
+
+ binding.recyclerView.setHasFixedSize(true)
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
+ binding.recyclerView.layoutManager = GridLayoutManager(this, 2)
+ } else {
+ binding.recyclerView.layoutManager = GridLayoutManager(this, 1)
+ }
+ addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+ binding.recyclerView.adapter = adapter
+
+ mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
+ mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
+
+ val toggle = ActionBarDrawerToggle(
+ this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
+ )
+ binding.drawerLayout.addDrawerListener(toggle)
+ toggle.syncState()
+ binding.navView.setNavigationItemSelectedListener(this)
+
+ initGroupTab()
+ setupViewModel()
+ migrateLegacy()
+
+ 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) {
+ override fun handleOnBackPressed() {
+ if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
+ binding.drawerLayout.closeDrawer(GravityCompat.START)
+ } else {
+ isEnabled = false
+ onBackPressedDispatcher.onBackPressed()
+ isEnabled = true
+ }
+ }
+ })
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ private fun setupViewModel() {
+ mainViewModel.updateListAction.observe(this) { index ->
+ if (index >= 0) {
+ adapter.notifyItemChanged(index)
+ } else {
+ adapter.notifyDataSetChanged()
+ }
+ }
+ mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
+ mainViewModel.isRunning.observe(this) { isRunning ->
+ adapter.isRunning = isRunning
+ if (isRunning) {
+ binding.fab.setImageResource(R.drawable.ic_stop_24dp)
+ binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
+ 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))
+ setTestState(getString(R.string.connection_not_connected))
+ binding.layoutTest.isFocusable = false
+ }
+ }
+ mainViewModel.startListenBroadcast()
+ mainViewModel.initAssets(assets)
+ }
+
+ private fun migrateLegacy() {
+ lifecycleScope.launch(Dispatchers.IO) {
+ val result = MigrateManager.migrateServerConfig2Profile()
+ launch(Dispatchers.Main) {
+ if (result) {
+ toast(getString(R.string.migration_success))
+ mainViewModel.reloadServerList()
+ } else {
+ //toast(getString(R.string.migration_fail))
+ }
+ }
+
+ }
+ }
+
+ private fun initGroupTab() {
+ binding.tabGroup.removeOnTabSelectedListener(tabGroupListener)
+ binding.tabGroup.removeAllTabs()
+ binding.tabGroup.isVisible = false
+
+ val (listId, listRemarks) = mainViewModel.getSubscriptions(this)
+ if (listId == null || listRemarks == null) {
+ return
+ }
+
+ for (it in listRemarks.indices) {
+ val tab = binding.tabGroup.newTab()
+ tab.text = listRemarks[it]
+ tab.tag = listId[it]
+ binding.tabGroup.addTab(tab)
+ }
+ val selectIndex =
+ listId.indexOf(mainViewModel.subscriptionId).takeIf { it >= 0 } ?: (listId.count() - 1)
+ binding.tabGroup.selectTab(binding.tabGroup.getTabAt(selectIndex))
+ binding.tabGroup.addOnTabSelectedListener(tabGroupListener)
+ binding.tabGroup.isVisible = true
+ }
+
+ private fun startV2Ray() {
+ if (MmkvManager.getSelectServer().isNullOrEmpty()) {
+ toast(R.string.title_file_chooser)
+ return
+ }
+ V2RayServiceManager.startVService(this)
+ }
+
+ private fun restartV2Ray() {
+ if (mainViewModel.isRunning.value == true) {
+ V2RayServiceManager.stopVService(this)
+ }
+ lifecycleScope.launch {
+ delay(500)
+ startV2Ray()
+ }
+ }
+
+ public override fun onResume() {
+ super.onResume()
+ mainViewModel.reloadServerList()
+ }
+
+ public override fun onPause() {
+ super.onPause()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_main, menu)
+
+ val searchItem = menu.findItem(R.id.search_view)
+ if (searchItem != null) {
+ val searchView = searchItem.actionView as SearchView
+ searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String?): Boolean = false
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ mainViewModel.filterConfig(newText.orEmpty())
+ return false
+ }
+ })
+
+ searchView.setOnCloseListener {
+ mainViewModel.filterConfig("")
+ false
+ }
+ }
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.import_qrcode -> {
+ importQRcode()
+ true
+ }
+
+ R.id.import_clipboard -> {
+ importClipboard()
+ true
+ }
+
+ R.id.import_local -> {
+ importConfigLocal()
+ true
+ }
+
+ R.id.import_manually_vmess -> {
+ importManually(EConfigType.VMESS.value)
+ true
+ }
+
+ R.id.import_manually_vless -> {
+ importManually(EConfigType.VLESS.value)
+ true
+ }
+
+ R.id.import_manually_ss -> {
+ importManually(EConfigType.SHADOWSOCKS.value)
+ true
+ }
+
+ R.id.import_manually_socks -> {
+ importManually(EConfigType.SOCKS.value)
+ true
+ }
+
+ R.id.import_manually_http -> {
+ importManually(EConfigType.HTTP.value)
+ true
+ }
+
+ R.id.import_manually_trojan -> {
+ importManually(EConfigType.TROJAN.value)
+ true
+ }
+
+ R.id.import_manually_wireguard -> {
+ importManually(EConfigType.WIREGUARD.value)
+ true
+ }
+
+ R.id.import_manually_hysteria2 -> {
+ importManually(EConfigType.HYSTERIA2.value)
+ true
+ }
+
+ R.id.export_all -> {
+ exportAll()
+ true
+ }
+
+ R.id.ping_all -> {
+ toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count()))
+ mainViewModel.testAllTcping()
+ true
+ }
+
+ R.id.real_ping_all -> {
+ toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count()))
+ mainViewModel.testAllRealPing()
+ true
+ }
+
+ R.id.service_restart -> {
+ restartV2Ray()
+ true
+ }
+
+ R.id.del_all_config -> {
+ delAllConfig()
+ true
+ }
+
+ R.id.del_duplicate_config -> {
+ delDuplicateConfig()
+ true
+ }
+
+ R.id.del_invalid_config -> {
+ delInvalidConfig()
+ true
+ }
+
+ R.id.sort_by_test_results -> {
+ sortByTestResults()
+ true
+ }
+
+ R.id.sub_update -> {
+ importConfigViaSub()
+ true
+ }
+
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun importManually(createConfigType: Int) {
+ startActivity(
+ Intent()
+ .putExtra("createConfigType", createConfigType)
+ .putExtra("subscriptionId", mainViewModel.subscriptionId)
+ .setClass(this, ServerActivity::class.java)
+ )
+ }
+
+ /**
+ * import config from qrcode
+ */
+ private fun importQRcode(): Boolean {
+ val permission = Manifest.permission.CAMERA
+ if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
+ scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
+ } else {
+ pendingAction = Action.IMPORT_QR_CODE_CONFIG
+ requestPermissionLauncher.launch(permission)
+ }
+ return true
+ }
+
+ /**
+ * import config from clipboard
+ */
+ private fun importClipboard()
+ : Boolean {
+ try {
+ val clipboard = Utils.getClipboard(this)
+ importBatchConfig(clipboard)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to import config from clipboard", e)
+ return false
+ }
+ return true
+ }
+
+ private fun importBatchConfig(server: String?) {
+ binding.pbWaiting.show()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ val (count, countSub) = AngConfigManager.importBatchConfig(server, mainViewModel.subscriptionId, true)
+ delay(500L)
+ withContext(Dispatchers.Main) {
+ when {
+ count > 0 -> {
+ toast(getString(R.string.title_import_config_count, count))
+ mainViewModel.reloadServerList()
+ }
+
+ countSub > 0 -> initGroupTab()
+ else -> toastError(R.string.toast_failure)
+ }
+ binding.pbWaiting.hide()
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ toastError(R.string.toast_failure)
+ binding.pbWaiting.hide()
+ }
+ Log.e(AppConfig.TAG, "Failed to import batch config", e)
+ }
+ }
+ }
+
+ /**
+ * import config from local config file
+ */
+ private fun importConfigLocal(): Boolean {
+ try {
+ showFileChooser()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to import config from local file", e)
+ return false
+ }
+ return true
+ }
+
+
+ /**
+ * import config from sub
+ */
+ private fun importConfigViaSub(): Boolean {
+ binding.pbWaiting.show()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ val count = mainViewModel.updateConfigViaSubAll()
+ delay(500L)
+ launch(Dispatchers.Main) {
+ if (count > 0) {
+ toast(getString(R.string.title_update_config_count, count))
+ mainViewModel.reloadServerList()
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ binding.pbWaiting.hide()
+ }
+ }
+ return true
+ }
+
+ private fun exportAll() {
+ binding.pbWaiting.show()
+ lifecycleScope.launch(Dispatchers.IO) {
+ val ret = mainViewModel.exportAllServer()
+ launch(Dispatchers.Main) {
+ if (ret > 0)
+ toast(getString(R.string.title_export_config_count, ret))
+ else
+ toastError(R.string.toast_failure)
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+
+ private fun delAllConfig() {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ binding.pbWaiting.show()
+ lifecycleScope.launch(Dispatchers.IO) {
+ val ret = mainViewModel.removeAllServer()
+ launch(Dispatchers.Main) {
+ mainViewModel.reloadServerList()
+ toast(getString(R.string.title_del_config_count, ret))
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ //do noting
+ }
+ .show()
+ }
+
+ private fun delDuplicateConfig() {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ binding.pbWaiting.show()
+ lifecycleScope.launch(Dispatchers.IO) {
+ val ret = mainViewModel.removeDuplicateServer()
+ launch(Dispatchers.Main) {
+ mainViewModel.reloadServerList()
+ toast(getString(R.string.title_del_duplicate_config_count, ret))
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ //do noting
+ }
+ .show()
+ }
+
+ private fun delInvalidConfig() {
+ AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ binding.pbWaiting.show()
+ lifecycleScope.launch(Dispatchers.IO) {
+ val ret = mainViewModel.removeInvalidServer()
+ launch(Dispatchers.Main) {
+ mainViewModel.reloadServerList()
+ toast(getString(R.string.title_del_config_count, ret))
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ //do noting
+ }
+ .show()
+ }
+
+ private fun sortByTestResults() {
+ binding.pbWaiting.show()
+ lifecycleScope.launch(Dispatchers.IO) {
+ mainViewModel.sortByTestResults()
+ launch(Dispatchers.Main) {
+ mainViewModel.reloadServerList()
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+
+ /**
+ * show file chooser
+ */
+ private fun showFileChooser() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.type = "*/*"
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
+
+ val permission = 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) {
+ pendingAction = Action.READ_CONTENT_FROM_URI
+ chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
+ } else {
+ requestPermissionLauncher.launch(permission)
+ }
+ }
+
+ /**
+ * read content from uri
+ */
+ private fun readContentFromUri(uri: Uri) {
+ val permission = 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 {
+ contentResolver.openInputStream(uri).use { input ->
+ importBatchConfig(input?.bufferedReader()?.readText())
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to read content from URI", e)
+ }
+ } else {
+ requestPermissionLauncher.launch(permission)
+ }
+ }
+
+ private fun setTestState(content: String?) {
+ binding.tvTestState.text = content
+ }
+
+// val mConnection = object : ServiceConnection {
+// override fun onServiceDisconnected(name: ComponentName?) {
+// }
+//
+// override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+// sendMsg(AppConfig.MSG_REGISTER_CLIENT, "")
+// }
+// }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
+ moveTaskToBack(false)
+ return true
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+
+ override fun onNavigationItemSelected(item: MenuItem): Boolean {
+ // Handle navigation view item clicks here.
+ when (item.itemId) {
+ R.id.sub_setting -> requestSubSettingActivity.launch(Intent(this, SubSettingActivity::class.java))
+ R.id.per_app_proxy_settings -> startActivity(Intent(this, PerAppProxyActivity::class.java))
+ R.id.routing_setting -> requestSubSettingActivity.launch(Intent(this, RoutingSettingActivity::class.java))
+ R.id.user_asset_setting -> startActivity(Intent(this, UserAssetActivity::class.java))
+ R.id.settings -> startActivity(
+ Intent(this, SettingsActivity::class.java)
+ .putExtra("isRunning", mainViewModel.isRunning.value == true)
+ )
+
+ R.id.promotion -> Utils.openUri(this, "${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}")
+ R.id.logcat -> startActivity(Intent(this, LogcatActivity::class.java))
+ R.id.check_for_update -> startActivity(Intent(this, CheckUpdateActivity::class.java))
+ R.id.about -> startActivity(Intent(this, AboutActivity::class.java))
+ }
+
+ binding.drawerLayout.closeDrawer(GravityCompat.START)
+ return true
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt
new file mode 100644
index 00000000..e7ea6211
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt
@@ -0,0 +1,362 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.graphics.Color
+import android.text.TextUtils
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AlertDialog
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.AngApplication.Companion.application
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ItemQrcodeBinding
+import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
+import com.v2ray.ang.databinding.ItemRecyclerMainBinding
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.ProfileItem
+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.helper.ItemTouchHelperAdapter
+import com.v2ray.ang.helper.ItemTouchHelperViewHolder
+import com.v2ray.ang.service.V2RayServiceManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter(), ItemTouchHelperAdapter {
+ companion object {
+ private const val VIEW_TYPE_ITEM = 1
+ private const val VIEW_TYPE_FOOTER = 2
+ }
+
+ private var mActivity: MainActivity = activity
+ private val share_method: Array by lazy {
+ mActivity.resources.getStringArray(R.array.share_method)
+ }
+ private val share_method_more: Array by lazy {
+ mActivity.resources.getStringArray(R.array.share_method_more)
+ }
+ var isRunning = false
+ private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
+
+ /**
+ * Gets the total number of items in the adapter (servers count + footer view)
+ * @return The total item count
+ */
+ override fun getItemCount() = mActivity.mainViewModel.serversCache.size + 1
+
+ override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
+ if (holder is MainViewHolder) {
+ val guid = mActivity.mainViewModel.serversCache[position].guid
+ val profile = mActivity.mainViewModel.serversCache[position].profile
+ val isCustom = profile.configType == EConfigType.CUSTOM
+
+ holder.itemView.setBackgroundColor(Color.TRANSPARENT)
+
+ //Name address
+ holder.itemMainBinding.tvName.text = profile.remarks
+ holder.itemMainBinding.tvStatistics.text = getAddress(profile)
+ holder.itemMainBinding.tvType.text = profile.configType.name
+
+ //TestResult
+ val aff = MmkvManager.decodeServerAffiliationInfo(guid)
+ holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
+ if ((aff?.testDelayMillis ?: 0L) < 0L) {
+ holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPingRed))
+ } else {
+ holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing))
+ }
+
+ //layoutIndicator
+ if (guid == MmkvManager.getSelectServer()) {
+ holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorAccent)
+ } else {
+ holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
+ }
+
+ //subscription remarks
+ val subRemarks = getSubscriptionRemarks(profile)
+ holder.itemMainBinding.tvSubscription.text = subRemarks
+ holder.itemMainBinding.layoutSubscription.visibility = if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
+
+ //layout
+ if (doubleColumnDisplay) {
+ holder.itemMainBinding.layoutShare.visibility = View.GONE
+ holder.itemMainBinding.layoutEdit.visibility = View.GONE
+ holder.itemMainBinding.layoutRemove.visibility = View.GONE
+ 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 {
+ shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
+ }
+ } else {
+ holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
+ holder.itemMainBinding.layoutEdit.visibility = View.VISIBLE
+ holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
+ 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 {
+ shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
+ }
+
+ holder.itemMainBinding.layoutEdit.setOnClickListener {
+ editServer(guid, profile)
+ }
+ holder.itemMainBinding.layoutRemove.setOnClickListener {
+ removeServer(guid, position)
+ }
+ }
+
+ holder.itemMainBinding.infoContainer.setOnClickListener {
+ setSelectServer(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()}")
+// }
+// }
+// }
+ }
+
+ /**
+ * Gets the server address information
+ * Hides part of IP or domain information for privacy protection
+ * @param profile The server configuration
+ * @return Formatted address string
+ */
+ private fun getAddress(profile: ProfileItem): String {
+ // Hide xxx:xxx:***/xxx.xxx.xxx.***
+ return "${
+ profile.server?.let {
+ if (it.contains(":"))
+ it.split(":").take(2).joinToString(":", postfix = ":***")
+ else
+ it.split('.').dropLast(1).joinToString(".", postfix = ".***")
+ }
+ } : ${profile.serverPort}"
+ }
+
+ /**
+ * Gets the subscription remarks information
+ * @param profile The server configuration
+ * @return Subscription remarks string, or empty string if none
+ */
+ private fun getSubscriptionRemarks(profile: ProfileItem): String {
+ val subRemarks =
+ if (mActivity.mainViewModel.subscriptionId.isEmpty())
+ MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
+ else
+ null
+ return subRemarks?.toString() ?: ""
+ }
+
+ /**
+ * 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, 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))
+ 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)
+ }
+ }
+
+ /**
+ * Shares full server configuration content to clipboard
+ * @param guid The server unique identifier
+ */
+ 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 {
+ 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) == true) {
+ 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, mActivity.mainViewModel.serversCache.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) {
+ V2RayServiceManager.stopVService(mActivity)
+ mActivity.lifecycleScope.launch {
+ try {
+ delay(500)
+ V2RayServiceManager.startVService(mActivity)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to restart V2Ray service", e)
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
+ return when (viewType) {
+ VIEW_TYPE_ITEM ->
+ MainViewHolder(ItemRecyclerMainBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+
+ else ->
+ FooterViewHolder(ItemRecyclerFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return if (position == mActivity.mainViewModel.serversCache.size) {
+ VIEW_TYPE_FOOTER
+ } else {
+ VIEW_TYPE_ITEM
+ }
+ }
+
+ open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ fun onItemSelected() {
+ itemView.setBackgroundColor(Color.LTGRAY)
+ }
+
+ fun onItemClear() {
+ itemView.setBackgroundColor(0)
+ }
+ }
+
+ class MainViewHolder(val itemMainBinding: ItemRecyclerMainBinding) :
+ BaseViewHolder(itemMainBinding.root), ItemTouchHelperViewHolder
+
+ class FooterViewHolder(val itemFooterBinding: ItemRecyclerFooterBinding) :
+ BaseViewHolder(itemFooterBinding.root)
+
+ override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
+ mActivity.mainViewModel.swapServer(fromPosition, toPosition)
+ notifyItemMoved(fromPosition, toPosition)
+ return true
+ }
+
+ override fun onItemMoveCompleted() {
+ // do nothing
+ }
+
+ override fun onItemDismiss(position: Int) {
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt
new file mode 100644
index 00000000..b0df22e5
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt
@@ -0,0 +1,279 @@
+package com.v2ray.ang.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import android.widget.Toast
+import androidx.appcompat.widget.SearchView
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.ANG_PACKAGE
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityBypassListBinding
+import com.v2ray.ang.dto.AppInfo
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.extension.v2RayApplication
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.AppManagerUtil
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.Utils
+import es.dmoral.toasty.Toasty
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.text.Collator
+
+class PerAppProxyActivity : BaseActivity() {
+ private val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) }
+
+ private var adapter: PerAppProxyAdapter? = null
+ private var appsAll: List? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+
+ title = getString(R.string.per_app_proxy_settings)
+
+ addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+
+ lifecycleScope.launch {
+ try {
+ binding.pbWaiting.show()
+ val blacklist = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
+ val apps = withContext(Dispatchers.IO) {
+ val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
+
+ if (blacklist != null) {
+ appsList.forEach { app ->
+ app.isSelected = if (blacklist.contains(app.packageName)) 1 else 0
+ }
+ appsList.sortedWith { p1, p2 ->
+ when {
+ p1.isSelected > p2.isSelected -> -1
+ p1.isSelected == p2.isSelected -> 0
+ else -> 1
+ }
+ }
+ } else {
+ val collator = Collator.getInstance()
+ appsList.sortedWith(compareBy(collator) { it.appName })
+ }
+ }
+
+ appsAll = apps
+ adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, blacklist)
+ binding.recyclerView.adapter = adapter
+ binding.pbWaiting.hide()
+ } catch (e: Exception) {
+ binding.pbWaiting.hide()
+ Log.e(ANG_PACKAGE, "Error loading apps", e)
+ }
+ }
+
+ binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
+ MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, isChecked)
+ }
+ binding.switchPerAppProxy.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
+
+ binding.switchBypassApps.setOnCheckedChangeListener { _, isChecked ->
+ MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, isChecked)
+ }
+ binding.switchBypassApps.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
+
+ binding.layoutSwitchBypassAppsTips.setOnClickListener {
+ Toasty.info(this, R.string.summary_pref_per_app_proxy, Toast.LENGTH_LONG, true).show()
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ adapter?.let {
+ MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, it.blacklist)
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_bypass_list, menu)
+
+ val searchItem = menu.findItem(R.id.search_view)
+ if (searchItem != null) {
+ val searchView = searchItem.actionView as SearchView
+ searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+ override fun onQueryTextSubmit(query: String?): Boolean = false
+
+ override fun onQueryTextChange(newText: String?): Boolean {
+ filterProxyApp(newText.orEmpty())
+ return false
+ }
+ })
+ }
+
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+
+ @SuppressLint("NotifyDataSetChanged")
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.select_all -> adapter?.let { it ->
+ val pkgNames = it.apps.map { it.packageName }
+ if (it.blacklist.containsAll(pkgNames)) {
+ it.apps.forEach {
+ val packageName = it.packageName
+ adapter?.blacklist?.remove(packageName)
+ }
+ } else {
+ it.apps.forEach {
+ val packageName = it.packageName
+ adapter?.blacklist?.add(packageName)
+ }
+ }
+ it.notifyDataSetChanged()
+ true
+ } == true
+
+ R.id.select_proxy_app -> {
+ selectProxyApp()
+ true
+ }
+
+ R.id.import_proxy_app -> {
+ importProxyApp()
+ true
+ }
+
+ R.id.export_proxy_app -> {
+ exportProxyApp()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun selectProxyApp() {
+ toast(R.string.msg_downloading_content)
+ binding.pbWaiting.show()
+
+ val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
+ lifecycleScope.launch(Dispatchers.IO) {
+ var content = HttpUtil.getUrlContent(url, 5000)
+ if (content.isNullOrEmpty()) {
+ val httpPort = SettingsManager.getHttpPort()
+ content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
+ }
+ launch(Dispatchers.Main) {
+ Log.i(AppConfig.TAG, content)
+ selectProxyApp(content, true)
+ toastSuccess(R.string.toast_success)
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+
+ private fun importProxyApp() {
+ val content = Utils.getClipboard(applicationContext)
+ if (TextUtils.isEmpty(content)) return
+ selectProxyApp(content, false)
+ toastSuccess(R.string.toast_success)
+ }
+
+ private fun exportProxyApp() {
+ var lst = binding.switchBypassApps.isChecked.toString()
+
+ adapter?.blacklist?.forEach block@{
+ lst = lst + System.getProperty("line.separator") + it
+ }
+ Utils.setClipboard(applicationContext, lst)
+ toastSuccess(R.string.toast_success)
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ private fun selectProxyApp(content: String, force: Boolean): Boolean {
+ try {
+ val proxyApps = if (TextUtils.isEmpty(content)) {
+ Utils.readTextFromAssets(v2RayApplication, "proxy_packagename.txt")
+ } else {
+ content
+ }
+ if (TextUtils.isEmpty(proxyApps)) return false
+
+ adapter?.blacklist?.clear()
+
+ if (binding.switchBypassApps.isChecked) {
+ adapter?.let { it ->
+ it.apps.forEach block@{
+ val packageName = it.packageName
+ Log.i(AppConfig.TAG, packageName)
+ if (!inProxyApps(proxyApps, packageName, force)) {
+ adapter?.blacklist?.add(packageName)
+ println(packageName)
+ return@block
+ }
+ }
+ it.notifyDataSetChanged()
+ }
+ } else {
+ adapter?.let { it ->
+ it.apps.forEach block@{
+ val packageName = it.packageName
+ Log.i(AppConfig.TAG, packageName)
+ if (inProxyApps(proxyApps, packageName, force)) {
+ adapter?.blacklist?.add(packageName)
+ println(packageName)
+ return@block
+ }
+ }
+ it.notifyDataSetChanged()
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Error selecting proxy app", e)
+ return false
+ }
+ return true
+ }
+
+ private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
+ if (force) {
+ if (packageName == "com.google.android.webview") return false
+ if (packageName.startsWith("com.google")) return true
+ }
+
+ return proxyApps.indexOf(packageName) >= 0
+ }
+
+ private fun filterProxyApp(content: String): Boolean {
+ val apps = ArrayList()
+
+ val key = content.uppercase()
+ if (key.isNotEmpty()) {
+ appsAll?.forEach {
+ if (it.appName.uppercase().indexOf(key) >= 0
+ || it.packageName.uppercase().indexOf(key) >= 0
+ ) {
+ apps.add(it)
+ }
+ }
+ } else {
+ appsAll?.forEach {
+ apps.add(it)
+ }
+ }
+
+ adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist)
+ binding.recyclerView.adapter = adapter
+ refreshData()
+ return true
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun refreshData() {
+ adapter?.notifyDataSetChanged()
+ }
+}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt
similarity index 77%
rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt
rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt
index ec7e1242..1d02b3b1 100644
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/PerAppProxyAdapter.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt
@@ -1,17 +1,14 @@
package com.v2ray.ang.ui
-import android.graphics.Color
-import androidx.recyclerview.widget.RecyclerView
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import com.v2ray.ang.R
+import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo
-import java.util.*
class PerAppProxyAdapter(val activity: BaseActivity, val apps: List, blacklist: MutableSet?) :
- RecyclerView.Adapter() {
+ RecyclerView.Adapter() {
companion object {
private const val VIEW_TYPE_HEADER = 0
@@ -35,8 +32,10 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List, bl
return when (viewType) {
VIEW_TYPE_HEADER -> {
val view = View(ctx)
- view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
- ctx.resources.getDimensionPixelSize(R.dimen.bypass_list_header_height) * 3)
+ view.layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0
+ )
BaseViewHolder(view)
}
// VIEW_TYPE_ITEM -> AppViewHolder(ctx.layoutInflater
@@ -52,29 +51,30 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List, bl
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
- View.OnClickListener {
+ View.OnClickListener {
private val inBlacklist: Boolean get() = blacklist.contains(appInfo.packageName)
private lateinit var appInfo: AppInfo
fun bind(appInfo: AppInfo) {
this.appInfo = appInfo
+ // Set app icon and name
itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
-// name.text = appInfo.appName
-
- itemBypassBinding.checkBox.isChecked = inBlacklist
- itemBypassBinding.packageName.text = appInfo.packageName
- if (appInfo.isSystemApp) {
- itemBypassBinding.name.text = String.format("** %1s", appInfo.appName)
- itemBypassBinding.name.setTextColor(Color.RED)
+ itemBypassBinding.name.text = if (appInfo.isSystemApp) {
+ String.format("** %s", appInfo.appName)
} else {
- itemBypassBinding.name.text = appInfo.appName
- itemBypassBinding.name.setTextColor(Color.DKGRAY)
+ appInfo.appName
}
+ // Set package name and checkbox state
+ itemBypassBinding.packageName.text = appInfo.packageName
+ itemBypassBinding.checkBox.isChecked = inBlacklist
+
+ // Handle item click to toggle blacklist status
itemView.setOnClickListener(this)
}
+
override fun onClick(v: View?) {
if (inBlacklist) {
blacklist.remove(appInfo.packageName)
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt
new file mode 100644
index 00000000..bb0ee572
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt
@@ -0,0 +1,132 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityRoutingEditBinding
+import com.v2ray.ang.dto.RulesetItem
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class RoutingEditActivity : BaseActivity() {
+ private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
+ private val position by lazy { intent.getIntExtra("position", -1) }
+
+ private val outbound_tag: Array by lazy {
+ resources.getStringArray(R.array.outbound_tag)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ title = getString(R.string.routing_settings_rule_title)
+
+ val rulesetItem = SettingsManager.getRoutingRuleset(position)
+ if (rulesetItem != null) {
+ bindingServer(rulesetItem)
+ } else {
+ clearServer()
+ }
+ }
+
+ private fun bindingServer(rulesetItem: RulesetItem): Boolean {
+ binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks)
+ binding.chkLocked.isChecked = rulesetItem.locked == true
+ binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
+ binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
+ binding.etPort.text = Utils.getEditable(rulesetItem.port)
+ binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
+ binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
+ val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag)
+ binding.spOutboundTag.setSelection(outbound)
+
+ return true
+ }
+
+ private fun clearServer(): Boolean {
+ binding.etRemarks.text = null
+ binding.spOutboundTag.setSelection(0)
+ return true
+ }
+
+ private fun saveServer(): Boolean {
+ val rulesetItem = SettingsManager.getRoutingRuleset(position) ?: RulesetItem()
+
+ rulesetItem.apply {
+ remarks = binding.etRemarks.text.toString()
+ locked = binding.chkLocked.isChecked
+ domain = binding.etDomain.text.toString().takeIf { it.isNotEmpty() }
+ ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
+ ip = binding.etIp.text.toString().takeIf { it.isNotEmpty() }
+ ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
+ protocol = binding.etProtocol.text.toString().takeIf { it.isNotEmpty() }
+ ?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
+ port = binding.etPort.text.toString().takeIf { it.isNotEmpty() }
+ network = binding.etNetwork.text.toString().takeIf { it.isNotEmpty() }
+ outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
+ }
+
+ if (rulesetItem.remarks.isNullOrEmpty()) {
+ toast(R.string.sub_setting_remarks)
+ return false
+ }
+
+ SettingsManager.saveRoutingRuleset(position, rulesetItem)
+ toastSuccess(R.string.toast_success)
+ finish()
+ return true
+ }
+
+
+ private fun deleteServer(): Boolean {
+ if (position >= 0) {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ lifecycleScope.launch(Dispatchers.IO) {
+ SettingsManager.removeRoutingRuleset(position)
+ launch(Dispatchers.Main) {
+ finish()
+ }
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ // do nothing
+ }
+ .show()
+ }
+ return true
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_server, menu)
+ val del_config = menu.findItem(R.id.del_config)
+
+ if (position < 0) {
+ del_config?.isVisible = false
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.del_config -> {
+ deleteServer()
+ true
+ }
+
+ R.id.save_config -> {
+ saveServer()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt
new file mode 100644
index 00000000..e585cff8
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt
@@ -0,0 +1,204 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
+import com.v2ray.ang.dto.RulesetItem
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class RoutingSettingActivity : BaseActivity() {
+ private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
+
+ var rulesets: MutableList = mutableListOf()
+ private val adapter by lazy { RoutingSettingRecyclerAdapter(this) }
+ private var mItemTouchHelper: ItemTouchHelper? = null
+ private val routing_domain_strategy: Array by lazy {
+ resources.getStringArray(R.array.routing_domain_strategy)
+ }
+ private val preset_rulesets: Array by lazy {
+ 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?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+
+ title = getString(R.string.routing_settings_title)
+
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+ binding.recyclerView.adapter = adapter
+
+ mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
+ mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
+
+ binding.tvDomainStrategySummary.text = getDomainStrategy()
+ binding.layoutDomainStrategy.setOnClickListener {
+ setDomainStrategy()
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshData()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_routing_setting, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
+ R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
+ R.id.import_predefined_rulesets -> importPredefined().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.export_rulesets_to_clipboard -> export2Clipboard().let { true }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun getDomainStrategy(): String {
+ return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: routing_domain_strategy.first()
+ }
+
+ private fun setDomainStrategy() {
+ android.app.AlertDialog.Builder(this).setItems(routing_domain_strategy.asList().toTypedArray()) { _, i ->
+ try {
+ val value = routing_domain_strategy[i]
+ MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
+ binding.tvDomainStrategySummary.text = value
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to set domain strategy", e)
+ }
+ }.show()
+ }
+
+ private fun importPredefined() {
+ AlertDialog.Builder(this).setItems(preset_rulesets.asList().toTypedArray()) { _, i ->
+ AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ try {
+ lifecycleScope.launch(Dispatchers.IO) {
+ SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, i)
+ launch(Dispatchers.Main) {
+ refreshData()
+ toastSuccess(R.string.toast_success)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ //do nothing
+ }
+ .show()
+ }.show()
+ }
+
+ private fun importFromClipboard() {
+ AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ val clipboard = try {
+ Utils.getClipboard(this)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
+ toastError(R.string.toast_failure)
+ return@setPositiveButton
+ }
+ lifecycleScope.launch(Dispatchers.IO) {
+ val result = SettingsManager.resetRoutingRulesets(clipboard)
+ withContext(Dispatchers.Main) {
+ if (result) {
+ refreshData()
+ toastSuccess(R.string.toast_success)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ }
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ //do nothing
+ }
+ .show()
+ }
+
+ private fun export2Clipboard() {
+ val rulesetList = MmkvManager.decodeRoutingRulesets()
+ if (rulesetList.isNullOrEmpty()) {
+ toastError(R.string.toast_failure)
+ } else {
+ Utils.setClipboard(this, JsonUtil.toJson(rulesetList))
+ toastSuccess(R.string.toast_success)
+ }
+ }
+
+ private val scanQRcodeForRulesets = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ importRulesetsFromQRcode(it.data?.getStringExtra("SCAN_RESULT"))
+ }
+ }
+
+ private fun importRulesetsFromQRcode(qrcode: String?): Boolean {
+ AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ lifecycleScope.launch(Dispatchers.IO) {
+ val result = SettingsManager.resetRoutingRulesets(qrcode)
+ withContext(Dispatchers.Main) {
+ if (result) {
+ refreshData()
+ toastSuccess(R.string.toast_success)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ }
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ //do nothing
+ }
+ .show()
+ return true
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun refreshData() {
+ rulesets.clear()
+ rulesets.addAll(MmkvManager.decodeRoutingRulesets() ?: mutableListOf())
+ adapter.notifyDataSetChanged()
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt
new file mode 100644
index 00000000..bd48ea59
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt
@@ -0,0 +1,80 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.graphics.Color
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.helper.ItemTouchHelperAdapter
+import com.v2ray.ang.helper.ItemTouchHelperViewHolder
+
+class RoutingSettingRecyclerAdapter(val activity: RoutingSettingActivity) : RecyclerView.Adapter(),
+ ItemTouchHelperAdapter {
+
+ private var mActivity: RoutingSettingActivity = activity
+ override fun getItemCount() = mActivity.rulesets.size
+
+ override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
+ val ruleset = mActivity.rulesets[position]
+
+ holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
+ holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.port)?.toString()
+ holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
+ holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
+ holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
+ holder.itemView.setBackgroundColor(Color.TRANSPARENT)
+
+ holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
+ mActivity.startActivity(
+ Intent(mActivity, RoutingEditActivity::class.java)
+ .putExtra("position", position)
+ )
+ }
+
+ holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
+ if (!it.isPressed) return@setOnCheckedChangeListener
+ ruleset.enabled = isChecked
+ SettingsManager.saveRoutingRuleset(position, ruleset)
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
+ return MainViewHolder(
+ ItemRecyclerRoutingSettingBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ class MainViewHolder(val itemRoutingSettingBinding: ItemRecyclerRoutingSettingBinding) :
+ BaseViewHolder(itemRoutingSettingBinding.root), ItemTouchHelperViewHolder
+
+ open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ fun onItemSelected() {
+ itemView.setBackgroundColor(Color.LTGRAY)
+ }
+
+ fun onItemClear() {
+ itemView.setBackgroundColor(0)
+ }
+ }
+
+ override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
+ SettingsManager.swapRoutingRuleset(fromPosition, toPosition)
+ notifyItemMoved(fromPosition, toPosition)
+ return true
+ }
+
+ override fun onItemMoveCompleted() {
+ mActivity.refreshData()
+ }
+
+ override fun onItemDismiss(position: Int) {
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt
new file mode 100644
index 00000000..9a972f93
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt
@@ -0,0 +1,52 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import com.v2ray.ang.R
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.AngConfigManager
+
+class ScScannerActivity : BaseActivity() {
+
+ 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?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_none)
+ importQRcode()
+ }
+
+ private fun importQRcode(): Boolean {
+ requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ return true
+ }
+
+ private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ val scanResult = it.data?.getStringExtra("SCAN_RESULT").orEmpty()
+ val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
+
+ if (count + countSub > 0) {
+ toastSuccess(R.string.toast_success)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+
+ startActivity(Intent(this, MainActivity::class.java))
+ }
+ finish()
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt
similarity index 69%
rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt
rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt
index 3d3101ef..0495318a 100644
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ScSwitchActivity.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt
@@ -1,8 +1,7 @@
package com.v2ray.ang.ui
-import com.v2ray.ang.R
-import com.v2ray.ang.util.Utils
import android.os.Bundle
+import com.v2ray.ang.R
import com.v2ray.ang.service.V2RayServiceManager
class ScSwitchActivity : BaseActivity() {
@@ -12,10 +11,10 @@ class ScSwitchActivity : BaseActivity() {
setContentView(R.layout.activity_none)
- if (V2RayServiceManager.v2rayPoint.isRunning) {
- Utils.stopVService(this)
+ if (V2RayServiceManager.isRunning()) {
+ V2RayServiceManager.stopVService(this)
} else {
- Utils.startVServiceFromToggle(this)
+ V2RayServiceManager.startVServiceFromToggle(this)
}
finish()
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt
new file mode 100644
index 00000000..2a971ac0
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt
@@ -0,0 +1,134 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.BitmapFactory
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.util.QRCodeDecoder
+import io.github.g00fy2.quickie.QRResult
+import io.github.g00fy2.quickie.ScanCustomCode
+import io.github.g00fy2.quickie.config.ScannerConfig
+
+class ScannerActivity : BaseActivity() {
+
+
+ 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?) {
+ super.onCreate(savedInstanceState)
+
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE) == true) {
+ launchScan()
+ }
+ }
+
+ private fun launchScan() {
+ scanQrCode.launch(
+ ScannerConfig.build {
+ setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
+ setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
+ setShowCloseButton(true) // show or hide (default) close button
+ }
+ )
+ }
+
+ private fun handleResult(result: QRResult) {
+ if (result is QRResult.QRSuccess) {
+ finished(result.content.rawValue.orEmpty())
+ } else {
+ finish()
+ }
+ }
+
+ private fun finished(text: String) {
+ val intent = Intent()
+ intent.putExtra("SCAN_RESULT", text)
+ setResult(RESULT_OK, intent)
+ finish()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_scanner, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.scan_code -> {
+ launchScan()
+ true
+ }
+
+ R.id.select_photo -> {
+ val permission = 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) {
+ showFileChooser()
+ } else {
+ requestPermissionLauncher.launch(permission)
+ }
+ true
+ }
+
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun showFileChooser() {
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.type = "image/*"
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
+ //intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+
+ try {
+ chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
+ } catch (ex: android.content.ActivityNotFoundException) {
+ toast(R.string.toast_require_file_manager)
+ }
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt
new file mode 100644
index 00000000..e9bdad66
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt
@@ -0,0 +1,652 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.Spinner
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.DEFAULT_PORT
+import com.v2ray.ang.AppConfig.PREF_ALLOW_INSECURE
+import com.v2ray.ang.AppConfig.REALITY
+import com.v2ray.ang.AppConfig.TLS
+import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
+import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.EConfigType
+import com.v2ray.ang.dto.NetworkType
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.extension.isNotNullEmpty
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.util.JsonUtil
+import com.v2ray.ang.util.Utils
+
+class ServerActivity : BaseActivity() {
+
+ private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
+ private val isRunning by lazy {
+ intent.getBooleanExtra("isRunning", false)
+ && editGuid.isNotEmpty()
+ && editGuid == MmkvManager.getSelectServer()
+ }
+ private val createConfigType by lazy {
+ EConfigType.fromInt(intent.getIntExtra("createConfigType", EConfigType.VMESS.value))
+ ?: EConfigType.VMESS
+ }
+ private val subscriptionId by lazy {
+ intent.getStringExtra("subscriptionId")
+ }
+
+ private val securitys: Array by lazy {
+ resources.getStringArray(R.array.securitys)
+ }
+ private val shadowsocksSecuritys: Array by lazy {
+ resources.getStringArray(R.array.ss_securitys)
+ }
+ private val flows: Array by lazy {
+ resources.getStringArray(R.array.flows)
+ }
+ private val networks: Array by lazy {
+ resources.getStringArray(R.array.networks)
+ }
+ private val tcpTypes: Array by lazy {
+ resources.getStringArray(R.array.header_type_tcp)
+ }
+ private val kcpAndQuicTypes: Array by lazy {
+ resources.getStringArray(R.array.header_type_kcp_and_quic)
+ }
+ private val grpcModes: Array by lazy {
+ resources.getStringArray(R.array.mode_type_grpc)
+ }
+ private val streamSecuritys: Array by lazy {
+ resources.getStringArray(R.array.streamsecurityxs)
+ }
+ private val allowinsecures: Array by lazy {
+ resources.getStringArray(R.array.allowinsecures)
+ }
+ private val uTlsItems: Array by lazy {
+ resources.getStringArray(R.array.streamsecurity_utls)
+ }
+ private val alpns: Array by lazy {
+ resources.getStringArray(R.array.streamsecurity_alpn)
+ }
+ private val xhttpMode: Array by lazy {
+ resources.getStringArray(R.array.xhttp_mode)
+ }
+
+
+ // Kotlin synthetics was used, but since it is removed in 1.8. We switch to old manual approach.
+ // We don't use AndroidViewBinding because, it is better to share similar logics for different
+ // protocols. Use findViewById manually ensures the xml are de-coupled with the activity logic.
+ private val et_remarks: EditText by lazy { findViewById(R.id.et_remarks) }
+ private val et_address: EditText by lazy { findViewById(R.id.et_address) }
+ private val et_port: EditText by lazy { findViewById(R.id.et_port) }
+ private val et_id: EditText by lazy { findViewById(R.id.et_id) }
+ private val et_security: EditText? by lazy { findViewById(R.id.et_security) }
+ private val sp_flow: Spinner? by lazy { findViewById(R.id.sp_flow) }
+ private val sp_security: Spinner? by lazy { findViewById(R.id.sp_security) }
+ private val sp_stream_security: Spinner? by lazy { findViewById(R.id.sp_stream_security) }
+ private val sp_allow_insecure: Spinner? by lazy { findViewById(R.id.sp_allow_insecure) }
+ private val container_allow_insecure: LinearLayout? by lazy { findViewById(R.id.lay_allow_insecure) }
+ private val et_sni: EditText? by lazy { findViewById(R.id.et_sni) }
+ private val container_sni: LinearLayout? by lazy { findViewById(R.id.lay_sni) }
+ private val sp_stream_fingerprint: Spinner? by lazy { findViewById(R.id.sp_stream_fingerprint) } //uTLS
+ private val container_fingerprint: LinearLayout? by lazy { findViewById(R.id.lay_stream_fingerprint) }
+ private val sp_network: Spinner? by lazy { findViewById(R.id.sp_network) }
+ private val sp_header_type: Spinner? by lazy { findViewById(R.id.sp_header_type) }
+ private val sp_header_type_title: TextView? by lazy { findViewById(R.id.sp_header_type_title) }
+ private val tv_request_host: TextView? by lazy { findViewById(R.id.tv_request_host) }
+ private val et_request_host: EditText? by lazy { findViewById(R.id.et_request_host) }
+ private val tv_path: TextView? by lazy { findViewById(R.id.tv_path) }
+ private val et_path: EditText? by lazy { findViewById(R.id.et_path) }
+ private val sp_stream_alpn: Spinner? by lazy { findViewById(R.id.sp_stream_alpn) } //uTLS
+ private val container_alpn: LinearLayout? by lazy { findViewById(R.id.lay_stream_alpn) }
+ private val et_public_key: EditText? by lazy { findViewById(R.id.et_public_key) }
+ private val et_preshared_key: EditText? by lazy { findViewById(R.id.et_preshared_key) }
+ private val container_public_key: LinearLayout? by lazy { findViewById(R.id.lay_public_key) }
+ private val et_short_id: EditText? by lazy { findViewById(R.id.et_short_id) }
+ private val container_short_id: LinearLayout? by lazy { findViewById(R.id.lay_short_id) }
+ private val et_spider_x: EditText? by lazy { findViewById(R.id.et_spider_x) }
+ private val container_spider_x: LinearLayout? by lazy { findViewById(R.id.lay_spider_x) }
+ private val et_reserved1: EditText? by lazy { findViewById(R.id.et_reserved1) }
+ private val et_local_address: EditText? by lazy { findViewById(R.id.et_local_address) }
+ private val et_local_mtu: EditText? by lazy { findViewById(R.id.et_local_mtu) }
+ 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_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_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
+ private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
+ private val layout_extra: LinearLayout? by lazy { findViewById(R.id.layout_extra) }
+
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ title = getString(R.string.title_server)
+
+ val config = MmkvManager.decodeServerConfig(editGuid)
+ when (config?.configType ?: createConfigType) {
+ EConfigType.VMESS -> setContentView(R.layout.activity_server_vmess)
+ EConfigType.CUSTOM -> return
+ EConfigType.SHADOWSOCKS -> setContentView(R.layout.activity_server_shadowsocks)
+ EConfigType.SOCKS -> setContentView(R.layout.activity_server_socks)
+ EConfigType.HTTP -> setContentView(R.layout.activity_server_socks)
+ EConfigType.VLESS -> setContentView(R.layout.activity_server_vless)
+ EConfigType.TROJAN -> setContentView(R.layout.activity_server_trojan)
+ EConfigType.WIREGUARD -> setContentView(R.layout.activity_server_wireguard)
+ EConfigType.HYSTERIA2 -> setContentView(R.layout.activity_server_hysteria2)
+ }
+ sp_network?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(
+ parent: AdapterView<*>?,
+ view: View?,
+ position: Int,
+ id: Long,
+ ) {
+ val types = transportTypes(networks[position])
+ sp_header_type?.isEnabled = types.size > 1
+ val adapter =
+ ArrayAdapter(this@ServerActivity, android.R.layout.simple_spinner_item, types)
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+ sp_header_type?.adapter = adapter
+ sp_header_type_title?.text =
+ when (networks[position]) {
+ NetworkType.GRPC.type -> getString(R.string.server_lab_mode_type)
+ NetworkType.XHTTP.type -> getString(R.string.server_lab_xhttp_mode)
+ else -> getString(R.string.server_lab_head_type)
+ }.orEmpty()
+ sp_header_type?.setSelection(
+ Utils.arrayFind(
+ types,
+ when (networks[position]) {
+ NetworkType.GRPC.type -> config?.mode
+ NetworkType.XHTTP.type -> config?.xhttpMode
+ else -> config?.headerType
+ }.orEmpty()
+ )
+ )
+
+ et_request_host?.text = Utils.getEditable(
+ when (networks[position]) {
+ //"quic" -> config?.quicSecurity
+ NetworkType.GRPC.type -> config?.authority
+ else -> config?.host
+ }.orEmpty()
+ )
+ et_path?.text = Utils.getEditable(
+ when (networks[position]) {
+ NetworkType.KCP.type -> config?.seed
+ //"quic" -> config?.quicKey
+ NetworkType.GRPC.type -> config?.serviceName
+ else -> config?.path
+ }.orEmpty()
+ )
+
+ tv_request_host?.text = Utils.getEditable(
+ getString(
+ when (networks[position]) {
+ NetworkType.TCP.type -> R.string.server_lab_request_host_http
+ NetworkType.WS.type -> R.string.server_lab_request_host_ws
+ NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_request_host_httpupgrade
+ NetworkType.XHTTP.type -> R.string.server_lab_request_host_xhttp
+ NetworkType.H2.type -> R.string.server_lab_request_host_h2
+ //"quic" -> R.string.server_lab_request_host_quic
+ NetworkType.GRPC.type -> R.string.server_lab_request_host_grpc
+ else -> R.string.server_lab_request_host
+ }
+ )
+ )
+
+ tv_path?.text = Utils.getEditable(
+ getString(
+ when (networks[position]) {
+ NetworkType.KCP.type -> R.string.server_lab_path_kcp
+ NetworkType.WS.type -> R.string.server_lab_path_ws
+ NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade
+ NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp
+ NetworkType.H2.type -> R.string.server_lab_path_h2
+ //"quic" -> R.string.server_lab_path_quic
+ NetworkType.GRPC.type -> R.string.server_lab_path_grpc
+ else -> R.string.server_lab_path
+ }
+ )
+ )
+ et_extra?.text = Utils.getEditable(
+ when (networks[position]) {
+ NetworkType.XHTTP.type -> config?.xhttpExtra
+ else -> null
+ }.orEmpty()
+ )
+
+ layout_extra?.visibility =
+ when (networks[position]) {
+ NetworkType.XHTTP.type -> View.VISIBLE
+ else -> View.GONE
+ }
+ }
+
+ override fun onNothingSelected(parent: AdapterView<*>?) {
+ // do nothing
+ }
+ }
+ sp_stream_security?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(
+ parent: AdapterView<*>?,
+ view: View?,
+ position: Int,
+ id: Long,
+ ) {
+ val isBlank = streamSecuritys[position].isBlank()
+ val isTLS = streamSecuritys[position] == TLS
+
+ when {
+ // Case 1: Null or blank
+ isBlank -> {
+ listOf(
+ container_sni, container_fingerprint, container_alpn,
+ container_allow_insecure, container_public_key,
+ container_short_id, container_spider_x
+ ).forEach { it?.visibility = View.GONE }
+ }
+
+ // Case 2: TLS value
+ isTLS -> {
+ listOf(
+ container_sni,
+ container_fingerprint,
+ container_alpn
+ ).forEach { it?.visibility = View.VISIBLE }
+ container_allow_insecure?.visibility = View.VISIBLE
+ listOf(
+ container_public_key,
+ container_short_id,
+ container_spider_x
+ ).forEach { it?.visibility = View.GONE }
+ }
+
+ // Case 3: Other reality values
+ else -> {
+ listOf(container_sni, container_fingerprint).forEach {
+ it?.visibility = View.VISIBLE
+ }
+ container_alpn?.visibility = View.GONE
+ container_allow_insecure?.visibility = View.GONE
+ listOf(
+ container_public_key,
+ container_short_id,
+ container_spider_x
+ ).forEach { it?.visibility = View.VISIBLE }
+ }
+ }
+ }
+
+ override fun onNothingSelected(p0: AdapterView<*>?) {
+ // do nothing
+ }
+ }
+ if (config != null) {
+ bindingServer(config)
+ } else {
+ clearServer()
+ }
+ }
+
+ /**
+ * binding selected server config
+ */
+ private fun bindingServer(config: ProfileItem): Boolean {
+
+ et_remarks.text = Utils.getEditable(config.remarks)
+ et_address.text = Utils.getEditable(config.server.orEmpty())
+ et_port.text = Utils.getEditable(config.serverPort ?: DEFAULT_PORT.toString())
+ et_id.text = Utils.getEditable(config.password.orEmpty())
+
+ if (config.configType == EConfigType.SOCKS || config.configType == EConfigType.HTTP) {
+ et_security?.text = Utils.getEditable(config.username.orEmpty())
+ } else if (config.configType == EConfigType.VLESS) {
+ et_security?.text = Utils.getEditable(config.method.orEmpty())
+ val flow = Utils.arrayFind(flows, config.flow.orEmpty())
+ if (flow >= 0) {
+ sp_flow?.setSelection(flow)
+ }
+ } else if (config.configType == EConfigType.WIREGUARD) {
+ et_id.text = Utils.getEditable(config.secretKey.orEmpty())
+ et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
+ et_preshared_key?.visibility = View.VISIBLE
+ et_preshared_key?.text = Utils.getEditable(config.preSharedKey.orEmpty())
+ et_reserved1?.text = Utils.getEditable(config.reserved ?: "0,0,0")
+ et_local_address?.text = Utils.getEditable(
+ config.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4
+ )
+ et_local_mtu?.text = Utils.getEditable(config.mtu?.toString() ?: WIREGUARD_LOCAL_MTU)
+ } else if (config.configType == EConfigType.HYSTERIA2) {
+ et_obfs_password?.text = Utils.getEditable(config.obfsPassword)
+ et_port_hop?.text = Utils.getEditable(config.portHopping)
+ 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_up?.text = Utils.getEditable(config.bandwidthUp)
+ }
+ val securityEncryptions =
+ if (config.configType == EConfigType.SHADOWSOCKS) shadowsocksSecuritys else securitys
+ val security = Utils.arrayFind(securityEncryptions, config.method.orEmpty())
+ if (security >= 0) {
+ sp_security?.setSelection(security)
+ }
+
+ val streamSecurity = Utils.arrayFind(streamSecuritys, config.security.orEmpty())
+ if (streamSecurity >= 0) {
+ sp_stream_security?.setSelection(streamSecurity)
+ container_sni?.visibility = View.VISIBLE
+ container_fingerprint?.visibility = View.VISIBLE
+ container_alpn?.visibility = View.VISIBLE
+
+ et_sni?.text = Utils.getEditable(config.sni)
+ config.fingerPrint?.let { it ->
+ val utlsIndex = Utils.arrayFind(uTlsItems, it)
+ utlsIndex.let { sp_stream_fingerprint?.setSelection(if (it >= 0) it else 0) }
+ }
+ config.alpn?.let { it ->
+ val alpnIndex = Utils.arrayFind(alpns, it)
+ alpnIndex.let { sp_stream_alpn?.setSelection(if (it >= 0) it else 0) }
+ }
+ if (config.security == TLS) {
+ container_allow_insecure?.visibility = View.VISIBLE
+ val allowinsecure = Utils.arrayFind(allowinsecures, config.insecure.toString())
+ if (allowinsecure >= 0) {
+ sp_allow_insecure?.setSelection(allowinsecure)
+ }
+ container_public_key?.visibility = View.GONE
+ container_short_id?.visibility = View.GONE
+ container_spider_x?.visibility = View.GONE
+ } else if (config.security == REALITY) {
+ container_public_key?.visibility = View.VISIBLE
+ et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
+ container_short_id?.visibility = View.VISIBLE
+ et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
+ container_spider_x?.visibility = View.VISIBLE
+ et_spider_x?.text = Utils.getEditable(config.spiderX.orEmpty())
+ container_allow_insecure?.visibility = View.GONE
+ }
+ }
+
+ if (config.security.isNullOrEmpty()) {
+ container_sni?.visibility = View.GONE
+ container_fingerprint?.visibility = View.GONE
+ container_alpn?.visibility = View.GONE
+ container_allow_insecure?.visibility = View.GONE
+ container_public_key?.visibility = View.GONE
+ container_short_id?.visibility = View.GONE
+ container_spider_x?.visibility = View.GONE
+ }
+ val network = Utils.arrayFind(networks, config.network.orEmpty())
+ if (network >= 0) {
+ sp_network?.setSelection(network)
+ }
+ return true
+ }
+
+ /**
+ * clear or init server config
+ */
+ private fun clearServer(): Boolean {
+ et_remarks.text = null
+ et_address.text = null
+ et_port.text = Utils.getEditable(DEFAULT_PORT.toString())
+ et_id.text = null
+ sp_security?.setSelection(0)
+ sp_network?.setSelection(0)
+
+ sp_header_type?.setSelection(0)
+ et_request_host?.text = null
+ et_path?.text = null
+ sp_stream_security?.setSelection(0)
+ sp_allow_insecure?.setSelection(0)
+ et_sni?.text = null
+
+ //et_security.text = null
+ sp_flow?.setSelection(0)
+ et_public_key?.text = null
+ et_reserved1?.text = Utils.getEditable("0,0,0")
+ et_local_address?.text =
+ Utils.getEditable(WIREGUARD_LOCAL_ADDRESS_V4)
+ et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU)
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun saveServer(): Boolean {
+ if (TextUtils.isEmpty(et_remarks.text.toString())) {
+ toast(R.string.server_lab_remarks)
+ return false
+ }
+ if (TextUtils.isEmpty(et_address.text.toString())) {
+ toast(R.string.server_lab_address)
+ return false
+ }
+ if (createConfigType != EConfigType.HYSTERIA2) {
+ if (Utils.parseInt(et_port.text.toString()) <= 0) {
+ toast(R.string.server_lab_port)
+ return false
+ }
+ }
+ val config =
+ MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(createConfigType)
+ if (config.configType != EConfigType.SOCKS
+ && config.configType != EConfigType.HTTP
+ && TextUtils.isEmpty(et_id.text.toString())
+ ) {
+ if (config.configType == EConfigType.TROJAN
+ || config.configType == EConfigType.SHADOWSOCKS
+ || config.configType == EConfigType.HYSTERIA2
+ ) {
+ toast(R.string.server_lab_id3)
+ } else {
+ toast(R.string.server_lab_id)
+ }
+ return false
+ }
+ sp_stream_security?.let {
+ if (config.configType == EConfigType.TROJAN && TextUtils.isEmpty(streamSecuritys[it.selectedItemPosition])) {
+ toast(R.string.server_lab_stream_security)
+ return false
+ }
+ }
+ if (et_extra?.text?.toString().isNotNullEmpty()) {
+ if (JsonUtil.parseString(et_extra?.text?.toString()) == null) {
+ toast(R.string.server_lab_xhttp_extra)
+ return false
+ }
+ }
+
+ saveCommon(config)
+ saveStreamSettings(config)
+ saveTls(config)
+
+ if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
+ config.subscriptionId = subscriptionId.orEmpty()
+ }
+ //Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
+ MmkvManager.encodeServerConfig(editGuid, config)
+ toastSuccess(R.string.toast_success)
+ finish()
+ return true
+ }
+
+ private fun saveCommon(config: ProfileItem) {
+ config.remarks = et_remarks.text.toString().trim()
+ config.server = et_address.text.toString().trim()
+ config.serverPort = et_port.text.toString().trim()
+ config.password = et_id.text.toString().trim()
+
+ if (config.configType == EConfigType.VMESS) {
+ config.method = securitys[sp_security?.selectedItemPosition ?: 0]
+ } else if (config.configType == EConfigType.VLESS) {
+ config.method = et_security?.text.toString().trim()
+ config.flow = flows[sp_flow?.selectedItemPosition ?: 0]
+ } else if (config.configType == EConfigType.SHADOWSOCKS) {
+ config.method = shadowsocksSecuritys[sp_security?.selectedItemPosition ?: 0]
+ } else if (config.configType == EConfigType.SOCKS || config.configType == EConfigType.HTTP) {
+ if (!TextUtils.isEmpty(et_security?.text) || !TextUtils.isEmpty(et_id.text)) {
+ config.username = et_security?.text.toString().trim()
+ }
+ } else if (config.configType == EConfigType.TROJAN) {
+ } else if (config.configType == EConfigType.WIREGUARD) {
+ config.secretKey = et_id.text.toString().trim()
+ config.publicKey = et_public_key?.text.toString().trim()
+ config.preSharedKey = et_preshared_key?.text.toString().trim()
+ config.reserved = et_reserved1?.text.toString().trim()
+ config.localAddress = et_local_address?.text.toString().trim()
+ config.mtu = Utils.parseInt(et_local_mtu?.text.toString())
+ } else if (config.configType == EConfigType.HYSTERIA2) {
+ config.obfsPassword = et_obfs_password?.text?.toString()
+ config.portHopping = et_port_hop?.text?.toString()
+ config.portHoppingInterval = et_port_hop_interval?.text?.toString()
+ config.pinSHA256 = et_pinsha256?.text?.toString()
+ config.bandwidthDown = et_bandwidth_down?.text?.toString()
+ config.bandwidthUp = et_bandwidth_up?.text?.toString()
+ }
+ }
+
+
+ private fun saveStreamSettings(profileItem: ProfileItem) {
+ val network = sp_network?.selectedItemPosition ?: return
+ val type = sp_header_type?.selectedItemPosition ?: return
+ val requestHost = et_request_host?.text?.toString()?.trim() ?: return
+ val path = et_path?.text?.toString()?.trim() ?: return
+
+ profileItem.network = networks[network]
+ profileItem.headerType = transportTypes(networks[network])[type]
+ profileItem.host = requestHost
+ profileItem.path = path
+ profileItem.seed = path
+ profileItem.quicSecurity = requestHost
+ profileItem.quicKey = path
+ profileItem.mode = transportTypes(networks[network])[type]
+ profileItem.serviceName = path
+ profileItem.authority = requestHost
+ profileItem.xhttpMode = transportTypes(networks[network])[type]
+ profileItem.xhttpExtra = et_extra?.text?.toString()?.trim()
+ }
+
+ private fun saveTls(config: ProfileItem) {
+ val streamSecurity = sp_stream_security?.selectedItemPosition ?: return
+ val sniField = et_sni?.text?.toString()?.trim()
+ val allowInsecureField = sp_allow_insecure?.selectedItemPosition
+ val utlsIndex = sp_stream_fingerprint?.selectedItemPosition ?: 0
+ val alpnIndex = sp_stream_alpn?.selectedItemPosition ?: 0
+ val publicKey = et_public_key?.text?.toString()
+ val shortId = et_short_id?.text?.toString()
+ val spiderX = et_spider_x?.text?.toString()
+
+ val allowInsecure =
+ if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
+ MmkvManager.decodeSettingsBool(PREF_ALLOW_INSECURE)
+ } else {
+ allowinsecures[allowInsecureField].toBoolean()
+ }
+
+ config.security = streamSecuritys[streamSecurity]
+ config.insecure = allowInsecure
+ config.sni = sniField
+ config.fingerPrint = uTlsItems[utlsIndex]
+ config.alpn = alpns[alpnIndex]
+ config.publicKey = publicKey
+ config.shortId = shortId
+ config.spiderX = spiderX
+ }
+
+ private fun transportTypes(network: String?): Array {
+ return when (network) {
+ NetworkType.TCP.type -> {
+ tcpTypes
+ }
+
+ NetworkType.KCP.type -> {
+ kcpAndQuicTypes
+ }
+
+ NetworkType.GRPC.type -> {
+ grpcModes
+ }
+
+ NetworkType.XHTTP.type -> {
+ xhttpMode
+ }
+
+ else -> {
+ arrayOf("---")
+ }
+ }
+ }
+
+ /**
+ * delete server config
+ */
+ private fun deleteServer(): Boolean {
+ if (editGuid.isNotEmpty()) {
+ if (editGuid != MmkvManager.getSelectServer()) {
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ MmkvManager.removeServer(editGuid)
+ finish()
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ // do nothing
+ }
+ .show()
+ } else {
+ MmkvManager.removeServer(editGuid)
+ finish()
+ }
+ } else {
+ application.toast(R.string.toast_action_not_allowed)
+ }
+ }
+ return true
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_server, menu)
+ val delButton = menu.findItem(R.id.del_config)
+ val saveButton = menu.findItem(R.id.save_config)
+
+ if (editGuid.isNotEmpty()) {
+ if (isRunning) {
+ delButton?.isVisible = false
+ saveButton?.isVisible = false
+ }
+ } else {
+ delButton?.isVisible = false
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.del_config -> {
+ deleteServer()
+ true
+ }
+
+ R.id.save_config -> {
+ saveServer()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt
similarity index 56%
rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt
rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt
index e445b744..44e0b32b 100644
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/ServerCustomConfigActivity.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt
@@ -2,42 +2,41 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
+import android.util.Log
import android.view.Menu
import android.view.MenuItem
-import android.widget.Toast
import androidx.appcompat.app.AlertDialog
+import com.blacksquircle.ui.editorkit.utils.EditorTheme
import com.blacksquircle.ui.language.json.JsonLanguage
-import com.google.gson.Gson
-import com.tencent.mmkv.MMKV
+import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.dto.EConfigType
-import com.v2ray.ang.dto.ServerConfig
-import com.v2ray.ang.dto.V2rayConfig
+import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast
-import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.fmt.CustomFmt
+import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
-import me.drakeet.support.toast.ToastCompat
class ServerCustomConfigActivity : BaseActivity() {
- private lateinit var binding: ActivityServerCustomConfigBinding
+ private val binding by lazy { ActivityServerCustomConfigBinding.inflate(layoutInflater) }
- private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
- private val serverRawStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
private val isRunning by lazy {
intent.getBooleanExtra("isRunning", false)
&& editGuid.isNotEmpty()
- && editGuid == mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER)
+ && editGuid == MmkvManager.getSelectServer()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- binding = ActivityServerCustomConfigBinding.inflate(layoutInflater)
- val view = binding.root
- setContentView(view)
+ setContentView(binding.root)
title = getString(R.string.title_server)
+ if (!Utils.getDarkModeStatus(this)) {
+ binding.editor.colorScheme = EditorTheme.INTELLIJ_LIGHT
+ }
binding.editor.language = JsonLanguage()
val config = MmkvManager.decodeServerConfig(editGuid)
if (config != null) {
@@ -45,20 +44,17 @@ class ServerCustomConfigActivity : BaseActivity() {
} else {
clearServer()
}
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
/**
- * bingding seleced server config
+ * Binding selected server config
*/
- private fun bindingServer(config: ServerConfig): Boolean {
+ private fun bindingServer(config: ProfileItem): Boolean {
binding.etRemarks.text = Utils.getEditable(config.remarks)
- val raw = serverRawStorage?.decodeString(editGuid)
- if (raw.isNullOrBlank()) {
- binding.editor.setTextContent(Utils.getEditable(config.fullConfig?.toPrettyPrinting().orEmpty()))
- } else {
- binding.editor.setTextContent(Utils.getEditable(raw))
- }
+ val raw = MmkvManager.decodeServerRaw(editGuid)
+ val configContent = raw.orEmpty()
+
+ binding.editor.setTextContent(Utils.getEditable(configContent))
return true
}
@@ -79,21 +75,24 @@ class ServerCustomConfigActivity : BaseActivity() {
return false
}
- val v2rayConfig = try {
- Gson().fromJson(binding.editor.text.toString(), V2rayConfig::class.java)
+ val profileItem = try {
+ CustomFmt.parse(binding.editor.text.toString())
} catch (e: Exception) {
- e.printStackTrace()
- ToastCompat.makeText(this, "${getString(R.string.toast_malformed_josn)} ${e.cause?.message}", Toast.LENGTH_LONG).show()
+ Log.e(AppConfig.TAG, "Failed to parse custom configuration", e)
+ toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
return false
}
- val config = MmkvManager.decodeServerConfig(editGuid) ?: ServerConfig.create(EConfigType.CUSTOM)
- config.remarks = binding.etRemarks.text.toString().trim()
- config.fullConfig = v2rayConfig
+ val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.CUSTOM)
+ binding.etRemarks.text.let {
+ config.remarks = if (it.isNullOrEmpty()) profileItem?.remarks.orEmpty() else it.toString()
+ }
+ config.server = profileItem?.server
+ config.serverPort = profileItem?.serverPort
MmkvManager.encodeServerConfig(editGuid, config)
- serverRawStorage?.encode(editGuid, binding.editor.text.toString())
- toast(R.string.toast_success)
+ MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
+ toastSuccess(R.string.toast_success)
finish()
return true
}
@@ -104,19 +103,22 @@ class ServerCustomConfigActivity : BaseActivity() {
private fun deleteServer(): Boolean {
if (editGuid.isNotEmpty()) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
- .setPositiveButton(android.R.string.ok) { _, _ ->
- MmkvManager.removeServer(editGuid)
- finish()
- }
- .show()
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ MmkvManager.removeServer(editGuid)
+ finish()
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ // do nothing
+ }
+ .show()
}
return true
}
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
- val delButton = menu?.findItem(R.id.del_config)
- val saveButton = menu?.findItem(R.id.save_config)
+ val delButton = menu.findItem(R.id.del_config)
+ val saveButton = menu.findItem(R.id.save_config)
if (editGuid.isNotEmpty()) {
if (isRunning) {
@@ -135,10 +137,12 @@ class ServerCustomConfigActivity : BaseActivity() {
deleteServer()
true
}
+
R.id.save_config -> {
saveServer()
true
}
+
else -> super.onOptionsItemSelected(item)
}
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt
new file mode 100644
index 00000000..6af64e3a
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt
@@ -0,0 +1,372 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.View
+import androidx.activity.viewModels
+import androidx.preference.CheckBoxPreference
+import androidx.preference.EditTextPreference
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceFragmentCompat
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequest
+import androidx.work.multiprocess.RemoteWorkManager
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.VPN
+import com.v2ray.ang.R
+import com.v2ray.ang.extension.toLongEx
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.service.SubscriptionUpdater
+import com.v2ray.ang.util.Utils
+import com.v2ray.ang.viewmodel.SettingsViewModel
+import java.util.concurrent.TimeUnit
+
+class SettingsActivity : BaseActivity() {
+ private val settingsViewModel: SettingsViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_settings)
+
+ title = getString(R.string.title_settings)
+
+ settingsViewModel.startListenPreferenceChange()
+ }
+
+ class SettingsFragment : PreferenceFragmentCompat() {
+
+ private val perAppProxy by lazy { findPreference(AppConfig.PREF_PER_APP_PROXY) }
+ private val localDns by lazy { findPreference(AppConfig.PREF_LOCAL_DNS_ENABLED) }
+ private val fakeDns by lazy { findPreference(AppConfig.PREF_FAKE_DNS_ENABLED) }
+ private val appendHttpProxy by lazy { findPreference(AppConfig.PREF_APPEND_HTTP_PROXY) }
+ private val localDnsPort by lazy { findPreference(AppConfig.PREF_LOCAL_DNS_PORT) }
+ private val vpnDns by lazy { findPreference(AppConfig.PREF_VPN_DNS) }
+ private val vpnBypassLan by lazy { findPreference(AppConfig.PREF_VPN_BYPASS_LAN) }
+ private val vpnInterfaceAddress by lazy { findPreference(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
+
+ private val mux by lazy { findPreference(AppConfig.PREF_MUX_ENABLED) }
+ private val muxConcurrency by lazy { findPreference(AppConfig.PREF_MUX_CONCURRENCY) }
+ private val muxXudpConcurrency by lazy { findPreference(AppConfig.PREF_MUX_XUDP_CONCURRENCY) }
+ private val muxXudpQuic by lazy { findPreference(AppConfig.PREF_MUX_XUDP_QUIC) }
+
+ private val fragment by lazy { findPreference(AppConfig.PREF_FRAGMENT_ENABLED) }
+ private val fragmentPackets by lazy { findPreference(AppConfig.PREF_FRAGMENT_PACKETS) }
+ private val fragmentLength by lazy { findPreference(AppConfig.PREF_FRAGMENT_LENGTH) }
+ private val fragmentInterval by lazy { findPreference(AppConfig.PREF_FRAGMENT_INTERVAL) }
+
+ private val autoUpdateCheck by lazy { findPreference(AppConfig.SUBSCRIPTION_AUTO_UPDATE) }
+ private val autoUpdateInterval by lazy { findPreference(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
+
+ private val socksPort by lazy { findPreference(AppConfig.PREF_SOCKS_PORT) }
+ private val remoteDns by lazy { findPreference(AppConfig.PREF_REMOTE_DNS) }
+ private val domesticDns by lazy { findPreference(AppConfig.PREF_DOMESTIC_DNS) }
+ private val dnsHosts by lazy { findPreference(AppConfig.PREF_DNS_HOSTS) }
+ private val delayTestUrl by lazy { findPreference(AppConfig.PREF_DELAY_TEST_URL) }
+ private val mode by lazy { findPreference(AppConfig.PREF_MODE) }
+
+ override fun onCreatePreferences(bundle: Bundle?, s: String?) {
+ addPreferencesFromResource(R.xml.pref_settings)
+
+ perAppProxy?.setOnPreferenceClickListener {
+ startActivity(Intent(activity, PerAppProxyActivity::class.java))
+ perAppProxy?.isChecked = true
+ false
+ }
+ localDns?.setOnPreferenceChangeListener { _, any ->
+ updateLocalDns(any as Boolean)
+ true
+ }
+ localDnsPort?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ localDnsPort?.summary =
+ if (TextUtils.isEmpty(nval)) AppConfig.PORT_LOCAL_DNS else nval
+ true
+ }
+ vpnDns?.setOnPreferenceChangeListener { _, any ->
+ vpnDns?.summary = any as String
+ true
+ }
+
+ mux?.setOnPreferenceChangeListener { _, newValue ->
+ updateMux(newValue as Boolean)
+ true
+ }
+ muxConcurrency?.setOnPreferenceChangeListener { _, newValue ->
+ updateMuxConcurrency(newValue as String)
+ true
+ }
+ muxXudpConcurrency?.setOnPreferenceChangeListener { _, newValue ->
+ updateMuxXudpConcurrency(newValue as String)
+ true
+ }
+
+ fragment?.setOnPreferenceChangeListener { _, newValue ->
+ updateFragment(newValue as Boolean)
+ 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 ->
+ val value = newValue as Boolean
+ autoUpdateCheck?.isChecked = value
+ autoUpdateInterval?.isEnabled = value
+ autoUpdateInterval?.text?.toLongEx()?.let {
+ if (newValue) configureUpdateTask(it) else cancelUpdateTask()
+ }
+ true
+ }
+ autoUpdateInterval?.setOnPreferenceChangeListener { _, any ->
+ var nval = any as String
+
+ // It must be greater than 15 minutes because WorkManager couldn't run tasks under 15 minutes intervals
+ nval =
+ if (TextUtils.isEmpty(nval) || nval.toLongEx() < 15) AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL else nval
+ autoUpdateInterval?.summary = nval
+ configureUpdateTask(nval.toLongEx())
+ true
+ }
+
+ socksPort?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ socksPort?.summary = if (TextUtils.isEmpty(nval)) AppConfig.PORT_SOCKS else nval
+ true
+ }
+
+ remoteDns?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ remoteDns?.summary = if (nval == "") AppConfig.DNS_PROXY else nval
+ true
+ }
+ domesticDns?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ domesticDns?.summary = if (nval == "") AppConfig.DNS_DIRECT else nval
+ true
+ }
+ dnsHosts?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ dnsHosts?.summary = nval
+ true
+ }
+ delayTestUrl?.setOnPreferenceChangeListener { _, any ->
+ val nval = any as String
+ delayTestUrl?.summary = if (nval == "") AppConfig.DELAY_TEST_URL else nval
+ true
+ }
+ mode?.setOnPreferenceChangeListener { _, newValue ->
+ updateMode(newValue.toString())
+ true
+ }
+ mode?.dialogLayoutResource = R.layout.preference_with_help_link
+ //loglevel.summary = "LogLevel"
+
+ }
+
+ override fun onStart() {
+ super.onStart()
+ updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
+ localDns?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
+ fakeDns?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
+ appendHttpProxy?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
+ localDnsPort?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT, AppConfig.PORT_LOCAL_DNS)
+ vpnDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
+
+ updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false))
+ 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")
+
+ updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false))
+ 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)
+ autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
+
+ 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)
+
+ initSharedPreference()
+ }
+
+ private fun initSharedPreference() {
+ listOf(
+ localDnsPort,
+ vpnDns,
+ muxConcurrency,
+ muxXudpConcurrency,
+ fragmentLength,
+ fragmentInterval,
+ autoUpdateInterval,
+ socksPort,
+ remoteDns,
+ domesticDns,
+ delayTestUrl
+ ).forEach { key ->
+ key?.text = key?.summary.toString()
+ }
+
+ listOf(
+ AppConfig.PREF_SNIFFING_ENABLED,
+ ).forEach { key ->
+ findPreference(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(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
+ ).forEach { key ->
+ if (MmkvManager.decodeSettingsString(key) != null) {
+ findPreference(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
+ fakeDns?.isEnabled = vpn
+ appendHttpProxy?.isEnabled = vpn
+ localDnsPort?.isEnabled = vpn
+ vpnDns?.isEnabled = vpn
+ vpnBypassLan?.isEnabled = vpn
+ vpnInterfaceAddress?.isEnabled = vpn
+ if (vpn) {
+ updateLocalDns(
+ MmkvManager.decodeSettingsBool(
+ AppConfig.PREF_LOCAL_DNS_ENABLED,
+ false
+ )
+ )
+ }
+ }
+
+ private fun updateLocalDns(enabled: Boolean) {
+ fakeDns?.isEnabled = enabled
+ localDnsPort?.isEnabled = enabled
+ vpnDns?.isEnabled = !enabled
+ }
+
+ private fun configureUpdateTask(interval: Long) {
+ val rw = RemoteWorkManager.getInstance(AngApplication.application)
+ rw.cancelUniqueWork(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
+ rw.enqueueUniquePeriodicWork(
+ AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE,
+ PeriodicWorkRequest.Builder(
+ SubscriptionUpdater.UpdateTask::class.java,
+ interval,
+ TimeUnit.MINUTES
+ )
+ .apply {
+ setInitialDelay(interval, TimeUnit.MINUTES)
+ }
+ .build()
+ )
+ }
+
+ private fun cancelUpdateTask() {
+ val rw = RemoteWorkManager.getInstance(AngApplication.application)
+ rw.cancelUniqueWork(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
+ }
+
+ private fun updateMux(enabled: Boolean) {
+ muxConcurrency?.isEnabled = enabled
+ muxXudpConcurrency?.isEnabled = enabled
+ muxXudpQuic?.isEnabled = enabled
+ if (enabled) {
+ updateMuxConcurrency(MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8"))
+ updateMuxXudpConcurrency(MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8"))
+ }
+ }
+
+ private fun updateMuxConcurrency(value: String?) {
+ val concurrency = value?.toIntOrNull() ?: 8
+ muxConcurrency?.summary = concurrency.toString()
+ }
+
+
+ private fun updateMuxXudpConcurrency(value: String?) {
+ if (value == null) {
+ muxXudpQuic?.isEnabled = true
+ } else {
+ val concurrency = value.toIntOrNull() ?: 8
+ muxXudpConcurrency?.summary = concurrency.toString()
+ muxXudpQuic?.isEnabled = concurrency >= 0
+ }
+ }
+
+ private fun updateFragment(enabled: Boolean) {
+ fragmentPackets?.isEnabled = enabled
+ fragmentLength?.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?) {
+ fragmentPackets?.summary = value.toString()
+ }
+
+ private fun updateFragmentLength(value: String?) {
+ fragmentLength?.summary = value.toString()
+ }
+
+ private fun updateFragmentInterval(value: String?) {
+ fragmentInterval?.summary = value.toString()
+ }
+ }
+
+ fun onModeHelpClicked(view: View) {
+ Utils.openUri(this, AppConfig.APP_WIKI_MODE)
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt
new file mode 100644
index 00000000..f85382f1
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt
@@ -0,0 +1,165 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivitySubEditBinding
+import com.v2ray.ang.dto.SubscriptionItem
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class SubEditActivity : BaseActivity() {
+ private val binding by lazy { ActivitySubEditBinding.inflate(layoutInflater) }
+
+ private var del_config: MenuItem? = null
+ private var save_config: MenuItem? = null
+
+ private val editSubId by lazy { intent.getStringExtra("subId").orEmpty() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ title = getString(R.string.title_sub_setting)
+
+ val subItem = MmkvManager.decodeSubscription(editSubId)
+ if (subItem != null) {
+ bindingServer(subItem)
+ } else {
+ clearServer()
+ }
+ }
+
+ /**
+ * binding selected server config
+ */
+ private fun bindingServer(subItem: SubscriptionItem): Boolean {
+ binding.etRemarks.text = Utils.getEditable(subItem.remarks)
+ binding.etUrl.text = Utils.getEditable(subItem.url)
+ binding.etFilter.text = Utils.getEditable(subItem.filter)
+ binding.chkEnable.isChecked = subItem.enabled
+ binding.autoUpdateCheck.isChecked = subItem.autoUpdate
+ binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
+ binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
+ binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
+ return true
+ }
+
+ /**
+ * clear or init server config
+ */
+ private fun clearServer(): Boolean {
+ binding.etRemarks.text = null
+ binding.etUrl.text = null
+ binding.etFilter.text = null
+ binding.chkEnable.isChecked = true
+ binding.etPreProfile.text = null
+ binding.etNextProfile.text = null
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun saveServer(): Boolean {
+ val subItem = MmkvManager.decodeSubscription(editSubId) ?: SubscriptionItem()
+
+ subItem.remarks = binding.etRemarks.text.toString()
+ subItem.url = binding.etUrl.text.toString()
+ subItem.filter = binding.etFilter.text.toString()
+ subItem.enabled = binding.chkEnable.isChecked
+ subItem.autoUpdate = binding.autoUpdateCheck.isChecked
+ subItem.prevProfile = binding.etPreProfile.text.toString()
+ subItem.nextProfile = binding.etNextProfile.text.toString()
+ subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
+
+ if (TextUtils.isEmpty(subItem.remarks)) {
+ toast(R.string.sub_setting_remarks)
+ return false
+ }
+ if (subItem.url.isNotEmpty()) {
+ if (!Utils.isValidUrl(subItem.url)) {
+ toast(R.string.toast_invalid_url)
+ return false
+ }
+
+ if (!Utils.isValidSubUrl(subItem.url)) {
+ toast(R.string.toast_insecure_url_protocol)
+ if (!subItem.allowInsecureUrl) {
+ return false
+ }
+ }
+ }
+
+ MmkvManager.encodeSubscription(editSubId, subItem)
+ toastSuccess(R.string.toast_success)
+ finish()
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun deleteServer(): Boolean {
+ if (editSubId.isNotEmpty()) {
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ lifecycleScope.launch(Dispatchers.IO) {
+ MmkvManager.removeSubscription(editSubId)
+ launch(Dispatchers.Main) {
+ finish()
+ }
+ }
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ // do nothing
+ }
+ .show()
+ } else {
+ lifecycleScope.launch(Dispatchers.IO) {
+ MmkvManager.removeSubscription(editSubId)
+ launch(Dispatchers.Main) {
+ finish()
+ }
+ }
+ }
+ }
+ return true
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_server, menu)
+ del_config = menu.findItem(R.id.del_config)
+ save_config = menu.findItem(R.id.save_config)
+
+ if (editSubId.isEmpty()) {
+ del_config?.isVisible = false
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.del_config -> {
+ deleteServer()
+ true
+ }
+
+ R.id.save_config -> {
+ saveServer()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt
new file mode 100644
index 00000000..ef27fcff
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt
@@ -0,0 +1,89 @@
+package com.v2ray.ang.ui
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivitySubSettingBinding
+import com.v2ray.ang.dto.SubscriptionItem
+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.helper.SimpleItemTouchHelperCallback
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class SubSettingActivity : BaseActivity() {
+ private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
+
+ var subscriptions: List> = listOf()
+ private val adapter by lazy { SubSettingRecyclerAdapter(this) }
+ private var mItemTouchHelper: ItemTouchHelper? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+
+ title = getString(R.string.title_sub_setting)
+
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+ binding.recyclerView.adapter = adapter
+
+ mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
+ mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshData()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_sub_setting, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.add_config -> {
+ startActivity(Intent(this, SubEditActivity::class.java))
+ true
+ }
+
+ R.id.sub_update -> {
+ binding.pbWaiting.show()
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ val count = AngConfigManager.updateConfigViaSubAll()
+ delay(500L)
+ launch(Dispatchers.Main) {
+ if (count > 0) {
+ toastSuccess(R.string.toast_success)
+ } else {
+ toastError(R.string.toast_failure)
+ }
+ binding.pbWaiting.hide()
+ }
+ }
+
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun refreshData() {
+ subscriptions = MmkvManager.decodeSubscriptions()
+ adapter.notifyDataSetChanged()
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt
new file mode 100644
index 00000000..cc2d5404
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt
@@ -0,0 +1,163 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.graphics.Color
+import android.text.TextUtils
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AlertDialog
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ItemQrcodeBinding
+import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.helper.ItemTouchHelperAdapter
+import com.v2ray.ang.helper.ItemTouchHelperViewHolder
+import com.v2ray.ang.util.QRCodeDecoder
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView.Adapter(), ItemTouchHelperAdapter {
+
+ private var mActivity: SubSettingActivity = activity
+
+ private val share_method: Array by lazy {
+ mActivity.resources.getStringArray(R.array.share_sub_method)
+ }
+
+ override fun getItemCount() = mActivity.subscriptions.size
+
+ override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
+ val subId = mActivity.subscriptions[position].first
+ val subItem = mActivity.subscriptions[position].second
+ holder.itemSubSettingBinding.tvName.text = subItem.remarks
+ holder.itemSubSettingBinding.tvUrl.text = subItem.url
+ holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
+ holder.itemView.setBackgroundColor(Color.TRANSPARENT)
+
+ holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
+ mActivity.startActivity(
+ Intent(mActivity, SubEditActivity::class.java)
+ .putExtra("subId", subId)
+ )
+ }
+
+ holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
+ removeSubscription(subId, position)
+ }
+
+ holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
+ if (!it.isPressed) return@setOnCheckedChangeListener
+ subItem.enabled = isChecked
+ MmkvManager.encodeSubscription(subId, subItem)
+
+ }
+
+ if (TextUtils.isEmpty(subItem.url)) {
+ holder.itemSubSettingBinding.layoutUrl.visibility = View.GONE
+ holder.itemSubSettingBinding.layoutShare.visibility = View.INVISIBLE
+ holder.itemSubSettingBinding.chkEnable.visibility = View.INVISIBLE
+ } else {
+ holder.itemSubSettingBinding.layoutUrl.visibility = View.VISIBLE
+ holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE
+ holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
+ holder.itemSubSettingBinding.layoutShare.setOnClickListener {
+ AlertDialog.Builder(mActivity)
+ .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) == true) {
+ 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) {
+ mActivity.lifecycleScope.launch(Dispatchers.IO) {
+ MmkvManager.removeSubscription(subId)
+ launch(Dispatchers.Main) {
+ notifyItemRemoved(position)
+ notifyItemRangeChanged(position, mActivity.subscriptions.size)
+ mActivity.refreshData()
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
+ return MainViewHolder(
+ ItemRecyclerSubSettingBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ class MainViewHolder(val itemSubSettingBinding: ItemRecyclerSubSettingBinding) :
+ BaseViewHolder(itemSubSettingBinding.root), ItemTouchHelperViewHolder
+
+ open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ fun onItemSelected() {
+ itemView.setBackgroundColor(Color.LTGRAY)
+ }
+
+ fun onItemClear() {
+ itemView.setBackgroundColor(0)
+ }
+ }
+
+ override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
+ SettingsManager.swapSubscriptions(fromPosition, toPosition)
+ notifyItemMoved(fromPosition, toPosition)
+ return true
+ }
+
+ override fun onItemMoveCompleted() {
+ mActivity.refreshData()
+ }
+
+ override fun onItemDismiss(position: Int) {
+ }
+}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt
similarity index 76%
rename from V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt
rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt
index 8ee9d68f..8600a4ee 100644
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/TaskerActivity.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt
@@ -1,51 +1,46 @@
package com.v2ray.ang.ui
-import android.app.Activity
+import android.content.Intent
import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import android.widget.ListView
-import java.util.ArrayList
-import com.v2ray.ang.R
-import android.content.Intent
-import android.text.TextUtils
-import android.view.Menu
-import android.view.MenuItem
-import com.google.zxing.WriterException
-import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityTaskerBinding
-import com.v2ray.ang.util.MmkvManager
+import com.v2ray.ang.handler.MmkvManager
class TaskerActivity : BaseActivity() {
- private lateinit var binding: ActivityTaskerBinding
+ private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) }
private var listview: ListView? = null
private var lstData: ArrayList = ArrayList()
private var lstGuid: ArrayList = ArrayList()
- private val serverStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- binding = ActivityTaskerBinding.inflate(layoutInflater)
- val view = binding.root
- setContentView(view)
+ setContentView(binding.root)
//add def value
lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
- serverStorage?.allKeys()?.forEach { key ->
+ MmkvManager.decodeServerList().forEach { key ->
MmkvManager.decodeServerConfig(key)?.let { config ->
lstData.add(config.remarks)
lstGuid.add(key)
}
}
- val adapter = ArrayAdapter(this,
- android.R.layout.simple_list_item_single_choice, lstData)
+ val adapter = ArrayAdapter(
+ this,
+ android.R.layout.simple_list_item_single_choice, lstData
+ )
listview = findViewById(R.id.listview) as ListView
- listview!!.adapter = adapter
+ listview?.adapter = adapter
init()
}
@@ -65,8 +60,8 @@ class TaskerActivity : BaseActivity() {
listview?.setItemChecked(pos, true)
}
}
- } catch (e: WriterException) {
- e.printStackTrace()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
}
}
@@ -91,13 +86,13 @@ class TaskerActivity : BaseActivity() {
intent.putExtra(AppConfig.TASKER_EXTRA_BUNDLE, extraBundle)
intent.putExtra(AppConfig.TASKER_EXTRA_STRING_BLURB, blurb)
- setResult(Activity.RESULT_OK, intent)
+ setResult(RESULT_OK, intent)
finish()
}
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
- val del_config = menu?.findItem(R.id.del_config)
+ val del_config = menu.findItem(R.id.del_config)
del_config?.isVisible = false
return super.onCreateOptionsMenu(menu)
}
@@ -106,11 +101,14 @@ class TaskerActivity : BaseActivity() {
R.id.del_config -> {
true
}
+
R.id.save_config -> {
confirmFinish()
true
}
+
else -> super.onOptionsItemSelected(item)
}
}
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt
new file mode 100644
index 00000000..364f442a
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt
@@ -0,0 +1,87 @@
+package com.v2ray.ang.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import androidx.lifecycle.lifecycleScope
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityLogcatBinding
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.handler.AngConfigManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.net.URLDecoder
+
+class UrlSchemeActivity : BaseActivity() {
+ private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+
+ try {
+ intent.apply {
+ if (action == Intent.ACTION_SEND) {
+ if ("text/plain" == type) {
+ intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
+ parseUri(it, null)
+ }
+ }
+ } else if (action == Intent.ACTION_VIEW) {
+ when (data?.host) {
+ "install-config" -> {
+ val uri: Uri? = intent.data
+ val shareUrl = uri?.getQueryParameter("url").orEmpty()
+ parseUri(shareUrl, uri?.fragment)
+ }
+
+ "install-sub" -> {
+ val uri: Uri? = intent.data
+ val shareUrl = uri?.getQueryParameter("url").orEmpty()
+ parseUri(shareUrl, uri?.fragment)
+ }
+
+ else -> {
+ toastError(R.string.toast_failure)
+ }
+ }
+ }
+ }
+
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Error processing URL scheme", e)
+ }
+ }
+
+ private fun parseUri(uriString: String?, fragment: String?) {
+ if (uriString.isNullOrEmpty()) {
+ return
+ }
+ Log.i(AppConfig.TAG, uriString)
+
+ var decodedUrl = URLDecoder.decode(uriString, "UTF-8")
+ val uri = Uri.parse(decodedUrl)
+ if (uri != null) {
+ if (uri.fragment.isNullOrEmpty() && !fragment.isNullOrEmpty()) {
+ decodedUrl += "#${fragment}"
+ }
+ Log.i(AppConfig.TAG, decodedUrl)
+ lifecycleScope.launch(Dispatchers.IO) {
+ val (count, countSub) = AngConfigManager.importBatchConfig(decodedUrl, "", false)
+ withContext(Dispatchers.Main) {
+ if (count + countSub > 0) {
+ toast(R.string.import_subscription_success)
+ } else {
+ toast(R.string.import_subscription_failure)
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt
new file mode 100644
index 00000000..efc0a39c
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt
@@ -0,0 +1,376 @@
+package com.v2ray.ang.ui
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.AlertDialog
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.OpenableColumns
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityUserAssetBinding
+import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
+import com.v2ray.ang.dto.AssetUrlItem
+import com.v2ray.ang.extension.concatUrl
+import com.v2ray.ang.extension.toTrafficString
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastError
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.HttpUtil
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.text.DateFormat
+import java.util.Date
+
+class UserAssetActivity : BaseActivity() {
+ private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) }
+
+ val extDir by lazy { File(Utils.userAssetPath(this)) }
+ val builtInGeoFiles = arrayOf("geosite.dat", "geoip.dat")
+
+ private val requestStoragePermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.type = "*/*"
+ intent.addCategory(Intent.CATEGORY_OPENABLE)
+
+ try {
+ chooseFile.launch(
+ Intent.createChooser(
+ intent,
+ getString(R.string.title_file_chooser)
+ )
+ )
+ } catch (ex: android.content.ActivityNotFoundException) {
+ toast(R.string.toast_require_file_manager)
+ }
+ } else {
+ toast(R.string.toast_permission_denied)
+ }
+ }
+
+ private val requestCameraPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java))
+ } else {
+ toast(R.string.toast_permission_denied)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ title = getString(R.string.title_user_asset_setting)
+
+ binding.recyclerView.setHasFixedSize(true)
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
+ binding.recyclerView.adapter = UserAssetAdapter()
+
+ binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
+ binding.layoutGeoFilesSources.setOnClickListener {
+ setGeoFilesSources()
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshData()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.menu_asset, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ // Use when to streamline the option selection
+ override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
+ R.id.add_file -> showFileChooser().let { true }
+ R.id.add_url -> startActivity(Intent(this, UserAssetUrlActivity::class.java)).let { true }
+ R.id.add_qrcode -> importAssetFromQRcode().let { true }
+ R.id.download_file -> downloadGeoFiles().let { true }
+ else -> super.onOptionsItemSelected(item)
+ }
+
+ private fun getGeoFilesSources(): String {
+ return MmkvManager.decodeSettingsString(AppConfig.PREF_GEO_FILES_SOURCES) ?: AppConfig.GEO_FILES_SOURCES.first()
+ }
+
+ private fun setGeoFilesSources() {
+ AlertDialog.Builder(this).setItems(AppConfig.GEO_FILES_SOURCES.toTypedArray()) { _, i ->
+ try {
+ val value = AppConfig.GEO_FILES_SOURCES[i]
+ MmkvManager.encodeSettings(AppConfig.PREF_GEO_FILES_SOURCES, value)
+ binding.tvGeoFilesSourcesSummary.text = value
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to set geo files sources", e)
+ }
+ }.show()
+ }
+
+ private fun showFileChooser() {
+ val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_IMAGES
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+ requestStoragePermissionLauncher.launch(permission)
+ }
+
+ private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val uri = result.data?.data
+ if (result.resultCode == RESULT_OK && uri != null) {
+ val assetId = Utils.getUuid()
+ runCatching {
+ val assetItem = AssetUrlItem(
+ getCursorName(uri) ?: uri.toString(),
+ "file"
+ )
+
+ val assetList = MmkvManager.decodeAssetUrls()
+ if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) {
+ toast(R.string.msg_remark_is_duplicate)
+ } else {
+ MmkvManager.encodeAsset(assetId, assetItem)
+ copyFile(uri)
+ }
+ }.onFailure {
+ toastError(R.string.toast_asset_copy_failed)
+ MmkvManager.removeAssetUrl(assetId)
+ }
+ }
+ }
+
+ private fun copyFile(uri: Uri): String {
+ val targetFile = File(extDir, getCursorName(uri) ?: uri.toString())
+ contentResolver.openInputStream(uri).use { inputStream ->
+ targetFile.outputStream().use { fileOut ->
+ inputStream?.copyTo(fileOut)
+ toastSuccess(R.string.toast_success)
+ refreshData()
+ }
+ }
+ return targetFile.path
+ }
+
+ private fun getCursorName(uri: Uri): String? = try {
+ contentResolver.query(uri, null, null, null, null)?.let { cursor ->
+ cursor.run {
+ if (moveToFirst()) getString(getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
+ else null
+ }.also { cursor.close() }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to get cursor name", e)
+ null
+ }
+
+ private fun importAssetFromQRcode(): Boolean {
+ requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ return true
+ }
+
+ private val scanQRCodeForAssetURL = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ importAsset(it.data?.getStringExtra("SCAN_RESULT"))
+ }
+ }
+
+ private fun importAsset(url: String?): Boolean {
+ try {
+ if (!Utils.isValidUrl(url)) {
+ toast(R.string.toast_invalid_url)
+ return false
+ }
+ // Send URL to UserAssetUrlActivity for Processing
+ startActivity(
+ Intent(this, UserAssetUrlActivity::class.java)
+ .putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url)
+ )
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to import asset from URL", e)
+ return false
+ }
+ return true
+ }
+
+ private fun downloadGeoFiles() {
+ binding.pbWaiting.show()
+ toast(R.string.msg_downloading_content)
+
+ val httpPort = SettingsManager.getHttpPort()
+ var assets = MmkvManager.decodeAssetUrls()
+ assets = addBuiltInGeoItems(assets)
+
+ var resultCount = 0
+ lifecycleScope.launch(Dispatchers.IO) {
+ assets.forEach {
+ try {
+ var result = downloadGeo(it.second, 15000, httpPort)
+ if (!result) {
+ result = downloadGeo(it.second, 15000, 0)
+ }
+ if (result)
+ resultCount++
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to download geo file: ${it.second.remarks}", e)
+ }
+ }
+ withContext(Dispatchers.Main) {
+ if (resultCount > 0) {
+ toast(getString(R.string.title_update_config_count, resultCount))
+ refreshData()
+ } else {
+ toast(getString(R.string.toast_failure))
+ }
+ binding.pbWaiting.hide()
+ }
+ }
+ }
+
+ private fun downloadGeo(item: AssetUrlItem, timeout: Int, httpPort: Int): Boolean {
+ val targetTemp = File(extDir, item.remarks + "_temp")
+ val target = File(extDir, item.remarks)
+ Log.i(AppConfig.TAG, "Downloading geo file: ${item.remarks} from ${item.url}")
+
+ val conn = HttpUtil.createProxyConnection(item.url, httpPort, timeout, timeout, needStream = true) ?: return false
+ try {
+ val inputStream = conn.inputStream
+ val responseCode = conn.responseCode
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ FileOutputStream(targetTemp).use { output ->
+ inputStream.copyTo(output)
+ }
+
+ targetTemp.renameTo(target)
+ }
+ return true
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
+ return false
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ private fun addBuiltInGeoItems(assets: List>): List> {
+ val list = mutableListOf>()
+ builtInGeoFiles
+ .filter { geoFile -> assets.none { it.second.remarks == geoFile } }
+ .forEach {
+ list.add(
+ Utils.getUuid() to AssetUrlItem(
+ it,
+ String.format(AppConfig.GITHUB_DOWNLOAD_URL, getGeoFilesSources()).concatUrl(it),
+ locked = true
+ )
+ )
+ }
+
+ return list + assets
+ }
+
+ fun initAssets() {
+ lifecycleScope.launch(Dispatchers.Default) {
+ SettingsManager.initAssets(this@UserAssetActivity, assets)
+ withContext(Dispatchers.Main) {
+ refreshData()
+ }
+ }
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
+ fun refreshData() {
+ binding.recyclerView.adapter?.notifyDataSetChanged()
+ }
+
+ inner class UserAssetAdapter : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
+ return UserAssetViewHolder(
+ ItemRecyclerUserAssetBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+ }
+
+ @SuppressLint("SetTextI18n")
+ override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
+ var assets = MmkvManager.decodeAssetUrls()
+ assets = addBuiltInGeoItems(assets)
+ val item = assets.getOrNull(position) ?: return
+// file with name == item.second.remarks
+ val file = extDir.listFiles()?.find { it.name == item.second.remarks }
+
+ holder.itemUserAssetBinding.assetName.text = item.second.remarks
+
+ if (file != null) {
+ val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
+ holder.itemUserAssetBinding.assetProperties.text =
+ "${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
+ } else {
+ holder.itemUserAssetBinding.assetProperties.text = getString(R.string.msg_file_not_found)
+ }
+
+ if (item.second.locked == true) {
+ holder.itemUserAssetBinding.layoutEdit.visibility = GONE
+ //holder.itemUserAssetBinding.layoutRemove.visibility = GONE
+ } else {
+ holder.itemUserAssetBinding.layoutEdit.visibility = item.second.url.let { if (it == "file") GONE else VISIBLE }
+ //holder.itemUserAssetBinding.layoutRemove.visibility = VISIBLE
+ }
+
+ holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
+ val intent = Intent(this@UserAssetActivity, UserAssetUrlActivity::class.java)
+ intent.putExtra("assetId", item.first)
+ startActivity(intent)
+ }
+ holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
+ AlertDialog.Builder(this@UserAssetActivity).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ file?.delete()
+ MmkvManager.removeAssetUrl(item.first)
+ initAssets()
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ //do noting
+ }
+ .show()
+ }
+ }
+
+ override fun getItemCount(): Int {
+ var assets = MmkvManager.decodeAssetUrls()
+ assets = addBuiltInGeoItems(assets)
+ return assets.size
+ }
+ }
+
+ class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
+ RecyclerView.ViewHolder(itemUserAssetBinding.root)
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt
new file mode 100644
index 00000000..3d390967
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt
@@ -0,0 +1,160 @@
+package com.v2ray.ang.ui
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.view.Menu
+import android.view.MenuItem
+import androidx.appcompat.app.AlertDialog
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.databinding.ActivityUserAssetUrlBinding
+import com.v2ray.ang.dto.AssetUrlItem
+import com.v2ray.ang.extension.toast
+import com.v2ray.ang.extension.toastSuccess
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.util.Utils
+import java.io.File
+
+class UserAssetUrlActivity : BaseActivity() {
+ // Receive QRcode URL from UserAssetActivity
+ companion object {
+ const val ASSET_URL_QRCODE = "ASSET_URL_QRCODE"
+ }
+
+ private val binding by lazy { ActivityUserAssetUrlBinding.inflate(layoutInflater) }
+
+ private var del_config: MenuItem? = null
+ private var save_config: MenuItem? = null
+
+ private val extDir by lazy { File(Utils.userAssetPath(this)) }
+ private val editAssetId by lazy { intent.getStringExtra("assetId").orEmpty() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ title = getString(R.string.title_user_asset_add_url)
+
+ val assetItem = MmkvManager.decodeAsset(editAssetId)
+ val assetUrlQrcode = intent.getStringExtra(ASSET_URL_QRCODE)
+ val assetNameQrcode = File(assetUrlQrcode.toString()).name
+ when {
+ assetItem != null -> bindingAsset(assetItem)
+ assetUrlQrcode != null -> {
+ binding.etRemarks.setText(assetNameQrcode)
+ binding.etUrl.setText(assetUrlQrcode)
+ }
+
+ else -> clearAsset()
+ }
+ }
+
+ /**
+ * bingding seleced asset config
+ */
+ private fun bindingAsset(assetItem: AssetUrlItem): Boolean {
+ binding.etRemarks.text = Utils.getEditable(assetItem.remarks)
+ binding.etUrl.text = Utils.getEditable(assetItem.url)
+ return true
+ }
+
+ /**
+ * clear or init asset config
+ */
+ private fun clearAsset(): Boolean {
+ binding.etRemarks.text = null
+ binding.etUrl.text = null
+ return true
+ }
+
+ /**
+ * save asset config
+ */
+ private fun saveServer(): Boolean {
+ var assetItem = MmkvManager.decodeAsset(editAssetId)
+ var assetId = editAssetId
+ if (assetItem != null) {
+ // remove file associated with the asset
+ val file = extDir.resolve(assetItem.remarks)
+ if (file.exists()) {
+ try {
+ file.delete()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to delete asset file: ${file.path}", e)
+ }
+ }
+ } else {
+ assetId = Utils.getUuid()
+ assetItem = AssetUrlItem()
+ }
+
+ assetItem.remarks = binding.etRemarks.text.toString()
+ assetItem.url = binding.etUrl.text.toString()
+
+ // check remarks unique
+ val assetList = MmkvManager.decodeAssetUrls()
+ if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) {
+ toast(R.string.msg_remark_is_duplicate)
+ return false
+ }
+
+
+ if (TextUtils.isEmpty(assetItem.remarks)) {
+ toast(R.string.sub_setting_remarks)
+ return false
+ }
+ if (TextUtils.isEmpty(assetItem.url)) {
+ toast(R.string.title_url)
+ return false
+ }
+
+ MmkvManager.encodeAsset(assetId, assetItem)
+ toastSuccess(R.string.toast_success)
+ finish()
+ return true
+ }
+
+ /**
+ * save server config
+ */
+ private fun deleteServer(): Boolean {
+ if (editAssetId.isNotEmpty()) {
+ AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ MmkvManager.removeAssetUrl(editAssetId)
+ finish()
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ // do nothing
+ }
+ .show()
+ }
+ return true
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.action_server, menu)
+ del_config = menu.findItem(R.id.del_config)
+ save_config = menu.findItem(R.id.save_config)
+
+ if (editAssetId.isEmpty()) {
+ del_config?.isVisible = false
+ }
+
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.del_config -> {
+ deleteServer()
+ true
+ }
+
+ R.id.save_config -> {
+ saveServer()
+ true
+ }
+
+ else -> super.onOptionsItemSelected(item)
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/AppManagerUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/AppManagerUtil.kt
new file mode 100644
index 00000000..577698ea
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/AppManagerUtil.kt
@@ -0,0 +1,40 @@
+package com.v2ray.ang.util
+
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import com.v2ray.ang.dto.AppInfo
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+object AppManagerUtil {
+ /**
+ * Load the list of network applications.
+ *
+ * @param context The context to use.
+ * @return A list of AppInfo objects representing the network applications.
+ */
+ suspend fun loadNetworkAppList(context: Context): ArrayList =
+ withContext(Dispatchers.IO) {
+ val packageManager = context.packageManager
+ val packages = packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS)
+ val apps = ArrayList()
+
+ for (pkg in packages) {
+ val applicationInfo = pkg.applicationInfo ?: continue
+
+ val appName = applicationInfo.loadLabel(packageManager).toString()
+ val appIcon = applicationInfo.loadIcon(packageManager) ?: continue
+ val isSystemApp = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM > 0
+
+ val appInfo = AppInfo(appName, pkg.packageName, appIcon, isSystemApp, 0)
+ apps.add(appInfo)
+ }
+
+ return@withContext apps
+ }
+
+ fun getLastUpdateTime(context: Context): Long =
+ context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
+
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt
new file mode 100644
index 00000000..7172728e
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/HttpUtil.kt
@@ -0,0 +1,223 @@
+package com.v2ray.ang.util
+
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.LOOPBACK
+import com.v2ray.ang.BuildConfig
+import com.v2ray.ang.util.Utils.encode
+import com.v2ray.ang.util.Utils.urlDecode
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.IDN
+import java.net.Inet6Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.Proxy
+import java.net.URL
+
+object HttpUtil {
+
+ /**
+ * Converts the domain part of a URL string to its IDN (Punycode, ASCII Compatible Encoding) format.
+ *
+ * For example, a URL like "https://例子.中国/path" will be converted to "https://xn--fsqu00a.xn--fiqs8s/path".
+ *
+ * @param str The URL string to convert (can contain non-ASCII characters in the domain).
+ * @return The URL string with the domain part converted to ASCII-compatible (Punycode) format.
+ */
+ fun toIdnUrl(str: String): String {
+ val url = URL(str)
+ val host = url.host
+ val asciiHost = IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED)
+ if (host != asciiHost) {
+ return str.replace(host, asciiHost)
+ } else {
+ return str
+ }
+ }
+
+ /**
+ * Converts a Unicode domain name to its IDN (Punycode, ASCII Compatible Encoding) format.
+ * If the input is an IP address or already an ASCII domain, returns the original string.
+ *
+ * @param domain The domain string to convert (can include non-ASCII internationalized characters).
+ * @return The domain in ASCII-compatible (Punycode) format, or the original string if input is an IP or already ASCII.
+ */
+ fun toIdnDomain(domain: String): String {
+ // Return as is if it's a pure IP address (IPv4 or IPv6)
+ if (Utils.isPureIpAddress(domain)) {
+ return domain
+ }
+
+ // Return as is if already ASCII (English domain or already punycode)
+ if (domain.all { it.code < 128 }) {
+ return domain
+ }
+
+ // Otherwise, convert to ASCII using IDN
+ return IDN.toASCII(domain, IDN.ALLOW_UNASSIGNED)
+ }
+
+ /**
+ * Resolves a hostname to an IP address, returns original input if it's already an IP
+ *
+ * @param host The hostname or IP address to resolve
+ * @param ipv6Preferred Whether to prefer IPv6 addresses, defaults to false
+ * @return The resolved IP address or the original input (if it's already an IP or resolution fails)
+ */
+ fun resolveHostToIP(host: String, ipv6Preferred: Boolean = false): List? {
+ try {
+ // If it's already an IP address, return it as a list
+ if (Utils.isPureIpAddress(host)) {
+ return null
+ }
+
+ // Get all IP addresses
+ val addresses = InetAddress.getAllByName(host)
+ if (addresses.isEmpty()) {
+ return null
+ }
+
+ // Sort addresses based on preference
+ val sortedAddresses = if (ipv6Preferred) {
+ addresses.sortedWith(compareByDescending { it is Inet6Address })
+ } else {
+ addresses.sortedWith(compareBy { it is Inet6Address })
+ }
+
+ val ipList = sortedAddresses.mapNotNull { it.hostAddress }
+
+ Log.i(AppConfig.TAG, "Resolved IPs for $host: ${ipList.joinToString()}")
+
+ return ipList
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to resolve host to IP", e)
+ return null
+ }
+ }
+
+
+ /**
+ * Retrieves the content of a URL as a string.
+ *
+ * @param url The URL to fetch content from.
+ * @param timeout The timeout value in milliseconds.
+ * @param httpPort The HTTP port to use.
+ * @return The content of the URL as a string.
+ */
+ fun getUrlContent(url: String, timeout: Int, httpPort: Int = 0): String? {
+ val conn = createProxyConnection(url, httpPort, timeout, timeout) ?: return null
+ try {
+ return conn.inputStream.bufferedReader().readText()
+ } catch (_: Exception) {
+ } finally {
+ conn.disconnect()
+ }
+ return null
+ }
+
+ /**
+ * Retrieves the content of a URL as a string with a custom User-Agent header.
+ *
+ * @param url The URL to fetch content from.
+ * @param timeout The timeout value in milliseconds.
+ * @param httpPort The HTTP port to use.
+ * @return The content of the URL as a string.
+ * @throws IOException If an I/O error occurs.
+ */
+ @Throws(IOException::class)
+ fun getUrlContentWithUserAgent(url: String?, timeout: Int = 15000, httpPort: Int = 0): String {
+ var currentUrl = url
+ var redirects = 0
+ val maxRedirects = 3
+
+ while (redirects++ < maxRedirects) {
+ if (currentUrl == null) continue
+ val conn = createProxyConnection(currentUrl, httpPort, timeout, timeout) ?: continue
+ conn.setRequestProperty("User-agent", "v2rayNG/${BuildConfig.VERSION_NAME}")
+ conn.connect()
+
+ val responseCode = conn.responseCode
+ when (responseCode) {
+ in 300..399 -> {
+ val location = conn.getHeaderField("Location")
+ conn.disconnect()
+ if (location.isNullOrEmpty()) {
+ throw IOException("Redirect location not found")
+ }
+ currentUrl = location
+ continue
+ }
+
+ else -> try {
+ return conn.inputStream.use { it.bufferedReader().readText() }
+ } finally {
+ conn.disconnect()
+ }
+ }
+ }
+ throw IOException("Too many redirects")
+ }
+
+ /**
+ * Creates an HttpURLConnection object connected through a proxy.
+ *
+ * @param urlStr The target URL address.
+ * @param port The port of the proxy server.
+ * @param connectTimeout The connection timeout in milliseconds (default is 15000 ms).
+ * @param readTimeout The read timeout in milliseconds (default is 15000 ms).
+ * @param needStream Whether the connection needs to support streaming.
+ * @return Returns a configured HttpURLConnection object, or null if it fails.
+ */
+ fun createProxyConnection(
+ urlStr: String,
+ port: Int,
+ connectTimeout: Int = 15000,
+ readTimeout: Int = 15000,
+ needStream: Boolean = false
+ ): HttpURLConnection? {
+
+ var conn: HttpURLConnection? = null
+ try {
+ val url = URL(urlStr)
+ // Create a connection
+ conn = if (port == 0) {
+ url.openConnection()
+ } else {
+ url.openConnection(
+ Proxy(
+ Proxy.Type.HTTP,
+ InetSocketAddress(LOOPBACK, port)
+ )
+ )
+ } as HttpURLConnection
+
+ // Set connection and read timeouts
+ conn.connectTimeout = connectTimeout
+ conn.readTimeout = readTimeout
+ if (!needStream) {
+ // Set request headers
+ conn.setRequestProperty("Connection", "close")
+ // Disable automatic redirects
+ conn.instanceFollowRedirects = false
+ // Disable caching
+ conn.useCaches = false
+ }
+
+ //Add Basic Authorization
+ url.userInfo?.let {
+ conn.setRequestProperty(
+ "Authorization",
+ "Basic ${encode(urlDecode(it))}"
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to create proxy connection", e)
+ // If an exception occurs, close the connection and return null
+ conn?.disconnect()
+ return null
+ }
+ return conn
+ }
+}
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/JsonUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/JsonUtil.kt
new file mode 100644
index 00000000..80a40fbf
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/JsonUtil.kt
@@ -0,0 +1,79 @@
+package com.v2ray.ang.util
+
+import android.util.Log
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import com.google.gson.JsonPrimitive
+import com.google.gson.JsonSerializationContext
+import com.google.gson.JsonSerializer
+import com.google.gson.reflect.TypeToken
+import com.v2ray.ang.AppConfig
+import java.lang.reflect.Type
+
+object JsonUtil {
+ private var gson = Gson()
+
+ /**
+ * Converts an object to its JSON representation.
+ *
+ * @param src The object to convert.
+ * @return The JSON representation of the object.
+ */
+ fun toJson(src: Any?): String {
+ return gson.toJson(src)
+ }
+
+ /**
+ * Parses a JSON string into an object of the specified class.
+ *
+ * @param src The JSON string to parse.
+ * @param cls The class of the object to parse into.
+ * @return The parsed object.
+ */
+ fun fromJson(src: String, cls: Class): T {
+ return gson.fromJson(src, cls)
+ }
+
+ /**
+ * Converts an object to its pretty-printed JSON representation.
+ *
+ * @param src The object to convert.
+ * @return The pretty-printed JSON representation of the object, or null if the object is null.
+ */
+ fun toJsonPretty(src: Any?): String? {
+ if (src == null)
+ return null
+ val gsonPre = GsonBuilder()
+ .setPrettyPrinting()
+ .disableHtmlEscaping()
+ .registerTypeAdapter( // custom serializer is needed here since JSON by default parse number as Double, core will fail to start
+ object : TypeToken() {}.type,
+ JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? ->
+ JsonPrimitive(
+ src?.toInt()
+ )
+ }
+ )
+ .create()
+ return gsonPre.toJson(src)
+ }
+
+ /**
+ * Parses a JSON string into a JsonObject.
+ *
+ * @param src The JSON string to parse.
+ * @return The parsed JsonObject, or null if parsing fails.
+ */
+ fun parseString(src: String?): JsonObject? {
+ if (src == null)
+ return null
+ try {
+ return JsonParser.parseString(src).getAsJsonObject()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to parse JSON string", e)
+ return null
+ }
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt
new file mode 100644
index 00000000..c84443c7
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt
@@ -0,0 +1,75 @@
+package com.v2ray.ang.util
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.service.V2RayTestService
+import java.io.Serializable
+
+object MessageUtil {
+
+
+ /**
+ * Sends a message to the service.
+ *
+ * @param ctx The context.
+ * @param what The message identifier.
+ * @param content The message content.
+ */
+ fun sendMsg2Service(ctx: Context, what: Int, content: Serializable) {
+ sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
+ }
+
+ /**
+ * Sends a message to the UI.
+ *
+ * @param ctx The context.
+ * @param what The message identifier.
+ * @param content The message content.
+ */
+ fun sendMsg2UI(ctx: Context, what: Int, content: Serializable) {
+ sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content)
+ }
+
+ /**
+ * Sends a message to the test service.
+ *
+ * @param ctx The context.
+ * @param what The message identifier.
+ * @param content The message content.
+ */
+ fun sendMsg2TestService(ctx: Context, what: Int, content: Serializable) {
+ try {
+ val intent = Intent()
+ intent.component = ComponentName(ctx, V2RayTestService::class.java)
+ intent.putExtra("key", what)
+ intent.putExtra("content", content)
+ ctx.startService(intent)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to send message to test service", e)
+ }
+ }
+
+ /**
+ * Sends a message with the specified action.
+ *
+ * @param ctx The context.
+ * @param action The action string.
+ * @param what The message identifier.
+ * @param content The message content.
+ */
+ private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
+ try {
+ val intent = Intent()
+ intent.action = action
+ intent.`package` = AppConfig.ANG_PACKAGE
+ intent.putExtra("key", what)
+ intent.putExtra("content", content)
+ ctx.sendBroadcast(intent)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to send message with action: $action", e)
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt
new file mode 100644
index 00000000..a769368f
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MyContextWrapper.kt
@@ -0,0 +1,39 @@
+package com.v2ray.ang.util
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.res.Configuration
+import android.content.res.Resources
+import android.os.Build
+import android.os.LocaleList
+import androidx.annotation.RequiresApi
+import java.util.Locale
+
+open class MyContextWrapper(base: Context?) : ContextWrapper(base) {
+ companion object {
+ /**
+ * Wraps the context with a new locale.
+ *
+ * @param context The original context.
+ * @param newLocale The new locale to set.
+ * @return A ContextWrapper with the new locale.
+ */
+ @RequiresApi(Build.VERSION_CODES.N)
+ fun wrap(context: Context, newLocale: Locale?): ContextWrapper {
+ var mContext = context
+ val res: Resources = mContext.resources
+ val configuration: Configuration = res.configuration
+ mContext = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ configuration.setLocale(newLocale)
+ val localeList = LocaleList(newLocale)
+ LocaleList.setDefault(localeList)
+ configuration.setLocales(localeList)
+ mContext.createConfigurationContext(configuration)
+ } else {
+ configuration.setLocale(newLocale)
+ mContext.createConfigurationContext(configuration)
+ }
+ return ContextWrapper(mContext)
+ }
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/PluginUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/PluginUtil.kt
new file mode 100644
index 00000000..2b9f71aa
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/PluginUtil.kt
@@ -0,0 +1,140 @@
+package com.v2ray.ang.util
+
+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.handler.SpeedtestManager
+import com.v2ray.ang.service.ProcessService
+import java.io.File
+
+object PluginUtil {
+ 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 {
+ 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)
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.java b/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.java
deleted file mode 100644
index 1a16ac3e..00000000
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.java
+++ /dev/null
@@ -1,116 +0,0 @@
-package com.v2ray.ang.util;
-
-import android.graphics.Bitmap;
-import android.graphics.BitmapFactory;
-
-import com.google.zxing.BarcodeFormat;
-import com.google.zxing.BinaryBitmap;
-import com.google.zxing.DecodeHintType;
-import com.google.zxing.MultiFormatReader;
-import com.google.zxing.RGBLuminanceSource;
-import com.google.zxing.Result;
-import com.google.zxing.common.GlobalHistogramBinarizer;
-import com.google.zxing.common.HybridBinarizer;
-
-import java.util.ArrayList;
-import java.util.EnumMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * 描述:解析二维码图片
- */
-public class QRCodeDecoder {
- public static final Map HINTS = new EnumMap<>(DecodeHintType.class);
-
- static {
- List allFormats = new ArrayList<>();
- allFormats.add(BarcodeFormat.AZTEC);
- allFormats.add(BarcodeFormat.CODABAR);
- allFormats.add(BarcodeFormat.CODE_39);
- allFormats.add(BarcodeFormat.CODE_93);
- allFormats.add(BarcodeFormat.CODE_128);
- allFormats.add(BarcodeFormat.DATA_MATRIX);
- allFormats.add(BarcodeFormat.EAN_8);
- allFormats.add(BarcodeFormat.EAN_13);
- allFormats.add(BarcodeFormat.ITF);
- allFormats.add(BarcodeFormat.MAXICODE);
- allFormats.add(BarcodeFormat.PDF_417);
- allFormats.add(BarcodeFormat.QR_CODE);
- allFormats.add(BarcodeFormat.RSS_14);
- allFormats.add(BarcodeFormat.RSS_EXPANDED);
- allFormats.add(BarcodeFormat.UPC_A);
- allFormats.add(BarcodeFormat.UPC_E);
- allFormats.add(BarcodeFormat.UPC_EAN_EXTENSION);
- HINTS.put(DecodeHintType.TRY_HARDER, BarcodeFormat.QR_CODE);
- HINTS.put(DecodeHintType.POSSIBLE_FORMATS, allFormats);
- HINTS.put(DecodeHintType.CHARACTER_SET, "utf-8");
- }
-
- private QRCodeDecoder() {
- }
-
- /**
- * 同步解析本地图片二维码。该方法是耗时操作,请在子线程中调用。
- *
- * @param picturePath 要解析的二维码图片本地路径
- * @return 返回二维码图片里的内容 或 null
- */
- public static String syncDecodeQRCode(String picturePath) {
- return syncDecodeQRCode(getDecodeAbleBitmap(picturePath));
- }
-
- /**
- * 同步解析bitmap二维码。该方法是耗时操作,请在子线程中调用。
- *
- * @param bitmap 要解析的二维码图片
- * @return 返回二维码图片里的内容 或 null
- */
- public static String syncDecodeQRCode(Bitmap bitmap) {
- Result result = null;
- RGBLuminanceSource source = null;
- try {
- int width = bitmap.getWidth();
- int height = bitmap.getHeight();
- int[] pixels = new int[width * height];
- bitmap.getPixels(pixels, 0, width, 0, 0, width, height);
- source = new RGBLuminanceSource(width, height, pixels);
- result = new MultiFormatReader().decode(new BinaryBitmap(new HybridBinarizer(source)), HINTS);
- return result.getText();
- } catch (Exception e) {
- e.printStackTrace();
- if (source != null) {
- try {
- result = new MultiFormatReader().decode(new BinaryBitmap(new GlobalHistogramBinarizer(source)), HINTS);
- return result.getText();
- } catch (Throwable e2) {
- e2.printStackTrace();
- }
- }
- return null;
- }
- }
-
- /**
- * 将本地图片文件转换成可解码二维码的 Bitmap。为了避免图片太大,这里对图片进行了压缩。感谢 https://github.com/devilsen 提的 PR
- *
- * @param picturePath 本地图片文件路径
- * @return
- */
- private static Bitmap getDecodeAbleBitmap(String picturePath) {
- try {
- BitmapFactory.Options options = new BitmapFactory.Options();
- options.inJustDecodeBounds = true;
- BitmapFactory.decodeFile(picturePath, options);
- int sampleSize = options.outHeight / 400;
- if (sampleSize <= 0) {
- sampleSize = 1;
- }
- options.inSampleSize = sampleSize;
- options.inJustDecodeBounds = false;
- return BitmapFactory.decodeFile(picturePath, options);
- } catch (Exception e) {
- return null;
- }
- }
-}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.kt
new file mode 100644
index 00000000..446739b6
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/QRCodeDecoder.kt
@@ -0,0 +1,123 @@
+package com.v2ray.ang.util
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.DecodeHintType
+import com.google.zxing.EncodeHintType
+import com.google.zxing.NotFoundException
+import com.google.zxing.RGBLuminanceSource
+import com.google.zxing.common.GlobalHistogramBinarizer
+import com.google.zxing.qrcode.QRCodeReader
+import com.google.zxing.qrcode.QRCodeWriter
+import java.util.EnumMap
+
+/**
+ * QR code decoder utility.
+ */
+object QRCodeDecoder {
+ val HINTS: MutableMap = EnumMap(DecodeHintType::class.java)
+
+ /**
+ * Creates a QR code bitmap from the given text.
+ *
+ * @param text The text to encode in the QR code.
+ * @param size The size of the QR code bitmap.
+ * @return The generated QR code bitmap, or null if an error occurs.
+ */
+ fun createQRCode(text: String, size: Int = 800): Bitmap? {
+ return runCatching {
+ val hints = mapOf(EncodeHintType.CHARACTER_SET to Charsets.UTF_8)
+ val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size, hints)
+ val pixels = IntArray(size * size) { i ->
+ if (bitMatrix.get(i % size, i / size)) 0xff000000.toInt() else 0xffffffff.toInt()
+ }
+ Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
+ setPixels(pixels, 0, size, 0, 0, size, size)
+ }
+ }.getOrNull()
+ }
+
+ /**
+ * Decodes a QR code from a local image file. This method is time-consuming and should be called in a background thread.
+ *
+ * @param picturePath The local path of the image file to decode.
+ * @return The content of the QR code, or null if decoding fails.
+ */
+ fun syncDecodeQRCode(picturePath: String): String? {
+ return syncDecodeQRCode(getDecodeAbleBitmap(picturePath))
+ }
+
+ /**
+ * Decodes a QR code from a bitmap. This method is time-consuming and should be called in a background thread.
+ *
+ * @param bitmap The bitmap to decode.
+ * @return The content of the QR code, or null if decoding fails.
+ */
+ fun syncDecodeQRCode(bitmap: Bitmap?): String? {
+ return bitmap?.let {
+ runCatching {
+ val pixels = IntArray(it.width * it.height).also { array ->
+ it.getPixels(array, 0, it.width, 0, 0, it.width, it.height)
+ }
+ val source = RGBLuminanceSource(it.width, it.height, pixels)
+ val qrReader = QRCodeReader()
+
+ try {
+ qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), mapOf(DecodeHintType.TRY_HARDER to true)).text
+ } catch (e: NotFoundException) {
+ qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())), mapOf(DecodeHintType.TRY_HARDER to true)).text
+ }
+ }.getOrNull()
+ }
+ }
+
+ /**
+ * Converts a local image file to a bitmap that can be decoded as a QR code. The image is compressed to avoid being too large.
+ *
+ * @param picturePath The local path of the image file.
+ * @return The decoded bitmap, or null if an error occurs.
+ */
+ private fun getDecodeAbleBitmap(picturePath: String): Bitmap? {
+ return try {
+ val options = BitmapFactory.Options()
+ options.inJustDecodeBounds = true
+ BitmapFactory.decodeFile(picturePath, options)
+ var sampleSize = options.outHeight / 400
+ if (sampleSize <= 0) {
+ sampleSize = 1
+ }
+ options.inSampleSize = sampleSize
+ options.inJustDecodeBounds = false
+ BitmapFactory.decodeFile(picturePath, options)
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ init {
+ val allFormats: List = arrayListOf(
+ BarcodeFormat.AZTEC,
+ BarcodeFormat.CODABAR,
+ BarcodeFormat.CODE_39,
+ BarcodeFormat.CODE_93,
+ BarcodeFormat.CODE_128,
+ BarcodeFormat.DATA_MATRIX,
+ BarcodeFormat.EAN_8,
+ BarcodeFormat.EAN_13,
+ BarcodeFormat.ITF,
+ BarcodeFormat.MAXICODE,
+ BarcodeFormat.PDF_417,
+ BarcodeFormat.QR_CODE,
+ BarcodeFormat.RSS_14,
+ BarcodeFormat.RSS_EXPANDED,
+ BarcodeFormat.UPC_A,
+ BarcodeFormat.UPC_E,
+ BarcodeFormat.UPC_EAN_EXTENSION
+ )
+ HINTS[DecodeHintType.TRY_HARDER] = BarcodeFormat.QR_CODE
+ HINTS[DecodeHintType.POSSIBLE_FORMATS] = allFormats
+ HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt
new file mode 100644
index 00000000..148ce4ec
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/Utils.kt
@@ -0,0 +1,570 @@
+package com.v2ray.ang.util
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration.UI_MODE_NIGHT_MASK
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import android.os.Build
+import android.os.LocaleList
+import android.provider.Settings
+import android.text.Editable
+import android.util.Base64
+import android.util.Log
+import android.util.Patterns
+import android.webkit.URLUtil
+import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.AppConfig.LOOPBACK
+import com.v2ray.ang.BuildConfig
+import java.io.IOException
+import java.net.InetAddress
+import java.net.ServerSocket
+import java.net.URI
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.util.Locale
+import java.util.UUID
+
+object Utils {
+
+ private val IPV4_REGEX =
+ Regex("^([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])\\.([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$")
+ private val IPV6_REGEX = Regex("^((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*::((?:[0-9A-Fa-f]{1,4}))?((?::[0-9A-Fa-f]{1,4}))*|((?:[0-9A-Fa-f]{1,4}))((?::[0-9A-Fa-f]{1,4})){7}$")
+
+ /**
+ * Convert string to editable for Kotlin.
+ *
+ * @param text The string to convert.
+ * @return An Editable instance containing the text.
+ */
+ fun getEditable(text: String?): Editable {
+ return Editable.Factory.getInstance().newEditable(text.orEmpty())
+ }
+
+ /**
+ * Find the position of a value in an array.
+ *
+ * @param array The array to search.
+ * @param value The value to find.
+ * @return The index of the value in the array, or -1 if not found.
+ */
+ fun arrayFind(array: Array, value: String): Int {
+ return array.indexOf(value)
+ }
+
+ /**
+ * Parse a string to an integer with a default value.
+ *
+ * @param str The string to parse.
+ * @param default The default value if parsing fails.
+ * @return The parsed integer, or the default value if parsing fails.
+ */
+ fun parseInt(str: String?, default: Int = 0): Int {
+ return str?.toIntOrNull() ?: default
+ }
+
+ /**
+ * Get text from the clipboard.
+ *
+ * @param context The context to use.
+ * @return The text from the clipboard, or an empty string if an error occurs.
+ */
+ fun getClipboard(context: Context): String {
+ return try {
+ val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ cmb.primaryClip?.getItemAt(0)?.text.toString()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
+ ""
+ }
+ }
+
+ /**
+ * Set text to the clipboard.
+ *
+ * @param context The context to use.
+ * @param content The text to set to the clipboard.
+ */
+ fun setClipboard(context: Context, content: String) {
+ try {
+ val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clipData = ClipData.newPlainText(null, content)
+ cmb.setPrimaryClip(clipData)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to set clipboard content", e)
+ }
+ }
+
+ /**
+ * Decode a base64 encoded string.
+ *
+ * @param text The base64 encoded string.
+ * @return The decoded string, or an empty string if decoding fails.
+ */
+ fun decode(text: String?): String {
+ return tryDecodeBase64(text) ?: text?.trimEnd('=')?.let { tryDecodeBase64(it) }.orEmpty()
+ }
+
+ /**
+ * Try to decode a base64 encoded string.
+ *
+ * @param text The base64 encoded string.
+ * @return The decoded string, or null if decoding fails.
+ */
+ fun tryDecodeBase64(text: String?): String? {
+ if (text.isNullOrEmpty()) return null
+
+ try {
+ return Base64.decode(text, Base64.NO_WRAP).toString(Charsets.UTF_8)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to decode standard base64", e)
+ }
+ try {
+ return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(Charsets.UTF_8)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to decode URL-safe base64", e)
+ }
+ return null
+ }
+
+ /**
+ * Encode a string to base64.
+ *
+ * @param text The string to encode.
+ * @return The base64 encoded string, or an empty string if encoding fails.
+ */
+ fun encode(text: String): String {
+ return try {
+ Base64.encodeToString(text.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to encode text to base64", e)
+ ""
+ }
+ }
+
+ /**
+ * Check if a string is a valid IP address.
+ *
+ * @param value The string to check.
+ * @return True if the string is a valid IP address, false otherwise.
+ */
+ fun isIpAddress(value: String?): Boolean {
+ if (value.isNullOrEmpty()) return false
+
+ try {
+ var addr = value.trim()
+ if (addr.isEmpty()) return false
+
+ //CIDR
+ if (addr.contains("/")) {
+ val arr = addr.split("/")
+ if (arr.size == 2 && arr[1].toIntOrNull() != null && arr[1].toInt() > -1) {
+ addr = arr[0]
+ }
+ }
+
+ // Handle IPv4-mapped IPv6 addresses
+ if (addr.startsWith("::ffff:") && '.' in addr) {
+ addr = addr.drop(7)
+ } else if (addr.startsWith("[::ffff:") && '.' in addr) {
+ addr = addr.drop(8).replace("]", "")
+ }
+
+ val octets = addr.split('.')
+ if (octets.size == 4) {
+ if (octets[3].contains(":")) {
+ addr = addr.substring(0, addr.indexOf(":"))
+ }
+ return isIpv4Address(addr)
+ }
+
+ return isIpv6Address(addr)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to validate IP address", e)
+ return false
+ }
+ }
+
+ /**
+ * Check if a string is a pure IP address (IPv4 or IPv6).
+ *
+ * @param value The string to check.
+ * @return True if the string is a pure IP address, false otherwise.
+ */
+ fun isPureIpAddress(value: String): Boolean {
+ return isIpv4Address(value) || isIpv6Address(value)
+ }
+
+ /**
+ * Check if a string is a valid domain name.
+ *
+ * A valid domain name must not be an IP address and must be a valid URL format.
+ *
+ * @param input The string to check.
+ * @return True if the string is a valid domain name, false otherwise.
+ */
+ fun isDomainName(input: String?): Boolean {
+ if (input.isNullOrEmpty()) return false
+
+ // Must not be an IP address and must be a valid URL format
+ return !isPureIpAddress(input) && isValidUrl(input)
+ }
+
+ /**
+ * Check if a string is a valid IPv4 address.
+ *
+ * @param value The string to check.
+ * @return True if the string is a valid IPv4 address, false otherwise.
+ */
+ private fun isIpv4Address(value: String): Boolean {
+ return IPV4_REGEX.matches(value)
+ }
+
+ /**
+ * Check if a string is a valid IPv6 address.
+ *
+ * @param value The string to check.
+ * @return True if the string is a valid IPv6 address, false otherwise.
+ */
+ private fun isIpv6Address(value: String): Boolean {
+ var addr = value
+ if (addr.startsWith("[") && addr.endsWith("]")) {
+ addr = addr.drop(1).dropLast(1)
+ }
+ return IPV6_REGEX.matches(addr)
+ }
+
+ /**
+ * Check if a string is a CoreDNS address.
+ *
+ * @param s The string to check.
+ * @return True if the string is a CoreDNS address, false otherwise.
+ */
+ fun isCoreDNSAddress(s: String): Boolean {
+ return s.startsWith("https") ||
+ s.startsWith("tcp") ||
+ s.startsWith("quic") ||
+ s == "localhost"
+ }
+
+ /**
+ * Check if a string is a valid URL.
+ *
+ * @param value The string to check.
+ * @return True if the string is a valid URL, false otherwise.
+ */
+ fun isValidUrl(value: String?): Boolean {
+ if (value.isNullOrEmpty()) return false
+
+ return try {
+ Patterns.WEB_URL.matcher(value).matches() ||
+ Patterns.DOMAIN_NAME.matcher(value).matches() ||
+ URLUtil.isValidUrl(value)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to validate URL", e)
+ false
+ }
+ }
+
+ /**
+ * Open a URI in a browser.
+ *
+ * @param context The context to use.
+ * @param uriString The URI string to open.
+ */
+ fun openUri(context: Context, uriString: String) {
+ try {
+ val uri = uriString.toUri()
+ context.startActivity(Intent(Intent.ACTION_VIEW, uri))
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to open URI", e)
+ }
+ }
+
+ /**
+ * Generate a UUID.
+ *
+ * @return A UUID string without dashes.
+ */
+ fun getUuid(): String {
+ return try {
+ UUID.randomUUID().toString().replace("-", "")
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to generate UUID", e)
+ ""
+ }
+ }
+
+ /**
+ * Decode a URL-encoded string.
+ *
+ * @param url The URL-encoded string.
+ * @return The decoded string, or the original string if decoding fails.
+ */
+ fun urlDecode(url: String): String {
+ return try {
+ URLDecoder.decode(url, Charsets.UTF_8.toString())
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to decode URL", e)
+ url
+ }
+ }
+
+ /**
+ * Encode a string to URL-encoded format.
+ *
+ * @param url The string to encode.
+ * @return The URL-encoded string, or the original string if encoding fails.
+ */
+ fun urlEncode(url: String): String {
+ return try {
+ URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to encode URL", e)
+ url
+ }
+ }
+
+ /**
+ * Read text from an asset file.
+ *
+ * @param context The context to use.
+ * @param fileName The name of the asset file.
+ * @return The content of the asset file as a string.
+ */
+ fun readTextFromAssets(context: Context?, fileName: String): String {
+ if (context == null) return ""
+
+ return try {
+ context.assets.open(fileName).use { inputStream ->
+ inputStream.bufferedReader().use { reader ->
+ reader.readText()
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to read asset file: $fileName", e)
+ ""
+ }
+ }
+
+ /**
+ * Get the path to the user asset directory.
+ *
+ * @param context The context to use.
+ * @return The path to the user asset directory.
+ */
+ fun userAssetPath(context: Context?): String {
+ if (context == null) return ""
+
+ return try {
+ context.getExternalFilesDir(AppConfig.DIR_ASSETS)?.absolutePath
+ ?: context.getDir(AppConfig.DIR_ASSETS, 0).absolutePath
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to get user asset path", e)
+ ""
+ }
+ }
+
+ /**
+ * Get the path to the backup directory.
+ *
+ * @param context The context to use.
+ * @return The path to the backup directory.
+ */
+ fun backupPath(context: Context?): String {
+ if (context == null) return ""
+
+ return try {
+ context.getExternalFilesDir(AppConfig.DIR_BACKUPS)?.absolutePath
+ ?: context.getDir(AppConfig.DIR_BACKUPS, 0).absolutePath
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to get backup path", e)
+ ""
+ }
+ }
+
+ /**
+ * Get the device ID for XUDP base key.
+ *
+ * @return The device ID for XUDP base key.
+ */
+ fun getDeviceIdForXUDPBaseKey(): String {
+ return try {
+ val androidId = Settings.Secure.ANDROID_ID.toByteArray(Charsets.UTF_8)
+ Base64.encodeToString(androidId.copyOf(32), Base64.NO_PADDING.or(Base64.URL_SAFE))
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to generate device ID", e)
+ ""
+ }
+ }
+
+ /**
+ * Get the dark mode status.
+ *
+ * @param context The context to use.
+ * @return True if dark mode is enabled, false otherwise.
+ */
+ fun getDarkModeStatus(context: Context): Boolean {
+ return context.resources.configuration.uiMode and UI_MODE_NIGHT_MASK != UI_MODE_NIGHT_NO
+ }
+
+ /**
+ * Get the IPv6 address in a formatted string.
+ *
+ * @param address The IPv6 address.
+ * @return The formatted IPv6 address, or the original address if not valid.
+ */
+ fun getIpv6Address(address: String?): String {
+ if (address.isNullOrEmpty()) return ""
+
+ return if (isIpv6Address(address) && !address.contains('[') && !address.contains(']')) {
+ "[$address]"
+ } else {
+ address
+ }
+ }
+
+ /**
+ * Get the system locale.
+ *
+ * @return The system locale.
+ */
+ fun getSysLocale(): Locale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ LocaleList.getDefault()[0]
+ } else {
+ Locale.getDefault()
+ }
+
+ /**
+ * Fix illegal characters in a URL.
+ *
+ * @param str The URL string.
+ * @return The URL string with illegal characters replaced.
+ */
+ fun fixIllegalUrl(str: String): String {
+ return str.replace(" ", "%20")
+ .replace("|", "%7C")
+ }
+
+ /**
+ * Find a free port from a list of ports.
+ *
+ * @param ports The list of ports to check.
+ * @return The first free port found.
+ * @throws IOException If no free port is found.
+ */
+ fun findFreePort(ports: List): Int {
+ for (port in ports) {
+ try {
+ return ServerSocket(port).use { it.localPort }
+ } catch (ex: IOException) {
+ continue // try next port
+ }
+ }
+
+ // if the program gets here, no port in the range was found
+ throw IOException("no free port found")
+ }
+
+ /**
+ * Check if a string is a valid subscription URL.
+ *
+ * @param value The string to check.
+ * @return True if the string is a valid subscription URL, false otherwise.
+ */
+ fun isValidSubUrl(value: String?): Boolean {
+ if (value.isNullOrEmpty()) return false
+
+ try {
+ if (URLUtil.isHttpsUrl(value)) return true
+ if (URLUtil.isHttpUrl(value)) {
+ if (value.contains(LOOPBACK)) return true
+
+ //Check private ip address
+ val uri = URI(fixIllegalUrl(value))
+ if (isIpAddress(uri.host)) {
+ AppConfig.PRIVATE_IP_LIST.forEach {
+ if (isIpInCidr(uri.host, it)) return true
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to validate subscription URL", e)
+ }
+ return false
+ }
+
+ /**
+ * Get the receiver flags based on the Android version.
+ *
+ * @return The receiver flags.
+ */
+ fun receiverFlags(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ ContextCompat.RECEIVER_EXPORTED
+ } else {
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ }
+
+ /**
+ * Check if the package is Xray.
+ *
+ * @return True if the package is Xray, false otherwise.
+ */
+ fun isXray(): Boolean = BuildConfig.APPLICATION_ID.startsWith("com.v2ray.ang")
+
+ /**
+ * Check if it is the Google Play version.
+ *
+ * @return True if the package is Google Play, false otherwise.
+ */
+ fun isGoogleFlavor(): Boolean = BuildConfig.FLAVOR == "playstore"
+
+ /**
+ * Converts an InetAddress to its long representation
+ *
+ * @param ip The InetAddress to convert
+ * @return The long representation of the IP address
+ */
+ private fun inetAddressToLong(ip: InetAddress): Long {
+ val bytes = ip.address
+ var result: Long = 0
+ for (i in bytes.indices) {
+ result = result shl 8 or (bytes[i].toInt() and 0xff).toLong()
+ }
+ return result
+ }
+
+ /**
+ * Check if an IP address is within a CIDR range
+ *
+ * @param ip The IP address to check
+ * @param cidr The CIDR notation range (e.g., "192.168.1.0/24")
+ * @return True if the IP is within the CIDR range, false otherwise
+ */
+ fun isIpInCidr(ip: String, cidr: String): Boolean {
+ try {
+ if (!isIpAddress(ip)) return false
+
+ // Parse CIDR (e.g., "192.168.1.0/24")
+ val (cidrIp, prefixLen) = cidr.split("/")
+ val prefixLength = prefixLen.toInt()
+
+ // Convert IP and CIDR's IP portion to Long
+ val ipLong = inetAddressToLong(InetAddress.getByName(ip))
+ val cidrIpLong = inetAddressToLong(InetAddress.getByName(cidrIp))
+
+ // Calculate subnet mask (e.g., /24 → 0xFFFFFF00)
+ val mask = if (prefixLength == 0) 0L else (-1L shl (32 - prefixLength))
+
+ // Check if they're in the same subnet
+ return (ipLong and mask) == (cidrIpLong and mask)
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to check if IP is in CIDR", e)
+ return false
+ }
+ }
+}
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt
new file mode 100644
index 00000000..9d9dce62
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/ZipUtil.kt
@@ -0,0 +1,125 @@
+package com.v2ray.ang.util
+
+import android.util.Log
+import com.v2ray.ang.AppConfig
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+import java.util.zip.ZipOutputStream
+
+object ZipUtil {
+ private const val BUFFER_SIZE = 4096
+
+ /**
+ * Zip the contents of a folder.
+ *
+ * @param folderPath The path to the folder to zip.
+ * @param outputZipFilePath The path to the output zip file.
+ * @return True if the operation is successful, false otherwise.
+ * @throws IOException If an I/O error occurs.
+ */
+ @Throws(IOException::class)
+ fun zipFromFolder(folderPath: String, outputZipFilePath: String): Boolean {
+ val buffer = ByteArray(BUFFER_SIZE)
+
+ try {
+ if (folderPath.isEmpty() || outputZipFilePath.isEmpty()) {
+ return false
+ }
+
+ val filesToCompress = ArrayList()
+ val directory = File(folderPath)
+ if (directory.isDirectory) {
+ directory.listFiles()?.forEach {
+ if (it.isFile) {
+ filesToCompress.add(it.absolutePath)
+ }
+ }
+ }
+ if (filesToCompress.isEmpty()) {
+ return false
+ }
+
+ val zos = ZipOutputStream(FileOutputStream(outputZipFilePath))
+
+ filesToCompress.forEach { file ->
+ val ze = ZipEntry(File(file).name)
+ zos.putNextEntry(ze)
+ val inputStream = FileInputStream(file)
+ while (true) {
+ val len = inputStream.read(buffer)
+ if (len <= 0) break
+ zos.write(buffer, 0, len)
+ }
+
+ inputStream.close()
+ }
+
+ zos.closeEntry()
+ zos.close()
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to zip folder", e)
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Unzip the contents of a zip file to a folder.
+ *
+ * @param zipFile The zip file to unzip.
+ * @param destDirectory The destination directory.
+ * @return True if the operation is successful, false otherwise.
+ * @throws IOException If an I/O error occurs.
+ */
+ @Throws(IOException::class)
+ fun unzipToFolder(zipFile: File, destDirectory: String): Boolean {
+ File(destDirectory).run {
+ if (!exists()) {
+ mkdirs()
+ }
+ }
+ try {
+ ZipFile(zipFile).use { zip ->
+ zip.entries().asSequence().forEach { entry ->
+ zip.getInputStream(entry).use { input ->
+ val filePath = destDirectory + File.separator + entry.name
+ if (!entry.isDirectory) {
+ extractFile(input, filePath)
+ } else {
+ val dir = File(filePath)
+ dir.mkdir()
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(AppConfig.TAG, "Failed to unzip file", e)
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Extract a file from an input stream.
+ *
+ * @param inputStream The input stream to read from.
+ * @param destFilePath The destination file path.
+ * @throws IOException If an I/O error occurs.
+ */
+ @Throws(IOException::class)
+ private fun extractFile(inputStream: InputStream, destFilePath: String) {
+ val bos = BufferedOutputStream(FileOutputStream(destFilePath))
+ val bytesIn = ByteArray(BUFFER_SIZE)
+ var read: Int
+ while (inputStream.read(bytesIn).also { read = it } != -1) {
+ bos.write(bytesIn, 0, read)
+ }
+ bos.close()
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt
new file mode 100644
index 00000000..ec5cb7ee
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt
@@ -0,0 +1,447 @@
+package com.v2ray.ang.viewmodel
+
+import android.app.Application
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.res.AssetManager
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import com.v2ray.ang.AngApplication
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.R
+import com.v2ray.ang.dto.ProfileItem
+import com.v2ray.ang.dto.ServersCache
+import com.v2ray.ang.extension.serializable
+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.SettingsManager
+import com.v2ray.ang.handler.SpeedtestManager
+import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import java.util.Collections
+
+class MainViewModel(application: Application) : AndroidViewModel(application) {
+ private var serverList = MmkvManager.decodeServerList()
+ var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
+
+ //var keywordFilter: String = MmkvManager.MmkvManager.decodeSettingsString(AppConfig.CACHE_KEYWORD_FILTER, "")?:""
+ var keywordFilter = ""
+ val serversCache = mutableListOf()
+ val isRunning by lazy { MutableLiveData() }
+ val updateListAction by lazy { MutableLiveData() }
+ val updateTestResultAction by lazy { MutableLiveData() }
+ private val tcpingTestScope by lazy { CoroutineScope(Dispatchers.IO) }
+
+ /**
+ * Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
+ * `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
+ */
+ fun startListenBroadcast() {
+ isRunning.value = false
+ val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY)
+ ContextCompat.registerReceiver(getApplication(), mMsgReceiver, mFilter, Utils.receiverFlags())
+ MessageUtil.sendMsg2Service(getApplication(), AppConfig.MSG_REGISTER_CLIENT, "")
+ }
+
+ /**
+ * Called when the ViewModel is cleared.
+ */
+ override fun onCleared() {
+ getApplication().unregisterReceiver(mMsgReceiver)
+ tcpingTestScope.coroutineContext[Job]?.cancelChildren()
+ SpeedtestManager.closeAllTcpSockets()
+ Log.i(AppConfig.TAG, "Main ViewModel is cleared")
+ super.onCleared()
+ }
+
+ /**
+ * Reloads the server list.
+ */
+ fun reloadServerList() {
+ serverList = MmkvManager.decodeServerList()
+ updateCache()
+ updateListAction.value = -1
+ }
+
+ /**
+ * Removes a server by its GUID.
+ * @param guid The GUID of the server to remove.
+ */
+ fun removeServer(guid: String) {
+ serverList.remove(guid)
+ MmkvManager.removeServer(guid)
+ val index = getPosition(guid)
+ if (index >= 0) {
+ serversCache.removeAt(index)
+ }
+ }
+
+// /**
+// * Appends a custom configuration server.
+// * @param server The server configuration to append.
+// * @return True if the server was successfully appended, false otherwise.
+// */
+// fun appendCustomConfigServer(server: String): Boolean {
+// if (server.contains("inbounds")
+// && server.contains("outbounds")
+// && server.contains("routing")
+// ) {
+// try {
+// val config = CustomFmt.parse(server) ?: return false
+// config.subscriptionId = subscriptionId
+// val key = MmkvManager.encodeServerConfig("", config)
+// MmkvManager.encodeServerRaw(key, server)
+// serverList.add(0, key)
+//// val profile = ProfileLiteItem(
+//// configType = config.configType,
+//// subscriptionId = config.subscriptionId,
+//// remarks = config.remarks,
+//// server = config.getProxyOutbound()?.getServerAddress(),
+//// serverPort = config.getProxyOutbound()?.getServerPort(),
+//// )
+// serversCache.add(0, ServersCache(key, config))
+// return true
+// } catch (e: Exception) {
+// e.printStackTrace()
+// }
+// }
+// return false
+// }
+
+ /**
+ * Swaps the positions of two servers.
+ * @param fromPosition The initial position of the server.
+ * @param toPosition The target position of the server.
+ */
+ fun swapServer(fromPosition: Int, toPosition: Int) {
+ if (subscriptionId.isEmpty()) {
+ Collections.swap(serverList, fromPosition, toPosition)
+ } else {
+ val fromPosition2 = serverList.indexOf(serversCache[fromPosition].guid)
+ val toPosition2 = serverList.indexOf(serversCache[toPosition].guid)
+ Collections.swap(serverList, fromPosition2, toPosition2)
+ }
+ Collections.swap(serversCache, fromPosition, toPosition)
+ MmkvManager.encodeServerList(serverList)
+ }
+
+ /**
+ * Updates the cache of servers.
+ */
+ @Synchronized
+ fun updateCache() {
+ serversCache.clear()
+ for (guid in serverList) {
+ var profile = MmkvManager.decodeServerConfig(guid) ?: continue
+// var profile = MmkvManager.decodeProfileConfig(guid)
+// if (profile == null) {
+// val config = MmkvManager.decodeServerConfig(guid) ?: continue
+// profile = ProfileLiteItem(
+// configType = config.configType,
+// subscriptionId = config.subscriptionId,
+// remarks = config.remarks,
+// server = config.getProxyOutbound()?.getServerAddress(),
+// serverPort = config.getProxyOutbound()?.getServerPort(),
+// )
+// MmkvManager.encodeServerConfig(guid, config)
+// }
+
+ if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) {
+ continue
+ }
+
+ if (keywordFilter.isEmpty() || profile.remarks.lowercase().contains(keywordFilter.lowercase())) {
+ serversCache.add(ServersCache(guid, profile))
+ }
+ }
+ }
+
+ /**
+ * Updates the configuration via subscription for all servers.
+ * @return The number of updated configurations.
+ */
+ fun updateConfigViaSubAll(): Int {
+ if (subscriptionId.isEmpty()) {
+ return AngConfigManager.updateConfigViaSubAll()
+ } else {
+ val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0
+ return AngConfigManager.updateConfigViaSub(Pair(subscriptionId, subItem))
+ }
+ }
+
+ /**
+ * Exports all servers.
+ * @return The number of exported servers.
+ */
+ fun exportAllServer(): Int {
+ val serverListCopy =
+ if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
+ serverList
+ } else {
+ serversCache.map { it.guid }.toList()
+ }
+
+ val ret = AngConfigManager.shareNonCustomConfigsToClipboard(
+ getApplication(),
+ serverListCopy
+ )
+ return ret
+ }
+
+ /**
+ * Tests the TCP ping for all servers.
+ */
+ fun testAllTcping() {
+ tcpingTestScope.coroutineContext[Job]?.cancelChildren()
+ SpeedtestManager.closeAllTcpSockets()
+ MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
+
+ val serversCopy = serversCache.toList()
+ for (item in serversCopy) {
+ item.profile.let { outbound ->
+ val serverAddress = outbound.server
+ val serverPort = outbound.serverPort
+ if (serverAddress != null && serverPort != null) {
+ tcpingTestScope.launch {
+ val testResult = SpeedtestManager.tcping(serverAddress, serverPort.toInt())
+ launch(Dispatchers.Main) {
+ MmkvManager.encodeServerTestDelayMillis(item.guid, testResult)
+ updateListAction.value = getPosition(item.guid)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Tests the real ping for all servers.
+ */
+ fun testAllRealPing() {
+ MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG_CANCEL, "")
+ MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
+ updateListAction.value = -1
+
+ val serversCopy = serversCache.toList()
+ viewModelScope.launch(Dispatchers.Default) {
+ for (item in serversCopy) {
+ MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, item.guid)
+ }
+ }
+ }
+
+ /**
+ * Tests the real ping for the current server.
+ */
+ fun testCurrentServerRealPing() {
+ MessageUtil.sendMsg2Service(getApplication(), AppConfig.MSG_MEASURE_DELAY, "")
+ }
+
+ /**
+ * Changes the subscription ID.
+ * @param id The new subscription ID.
+ */
+ fun subscriptionIdChanged(id: String) {
+ if (subscriptionId != id) {
+ subscriptionId = id
+ MmkvManager.encodeSettings(AppConfig.CACHE_SUBSCRIPTION_ID, subscriptionId)
+ reloadServerList()
+ }
+ }
+
+ /**
+ * Gets the subscriptions.
+ * @param context The context.
+ * @return A pair of lists containing the subscription IDs and remarks.
+ */
+ fun getSubscriptions(context: Context): Pair?, MutableList?> {
+ val subscriptions = MmkvManager.decodeSubscriptions()
+ if (subscriptionId.isNotEmpty()
+ && !subscriptions.map { it.first }.contains(subscriptionId)
+ ) {
+ subscriptionIdChanged("")
+ }
+ if (subscriptions.isEmpty()) {
+ return null to null
+ }
+ val listId = subscriptions.map { it.first }.toMutableList()
+ listId.add(0, "")
+ val listRemarks = subscriptions.map { it.second.remarks }.toMutableList()
+ listRemarks.add(0, context.getString(R.string.filter_config_all))
+
+ return listId to listRemarks
+ }
+
+ /**
+ * Gets the position of a server by its GUID.
+ * @param guid The GUID of the server.
+ * @return The position of the server.
+ */
+ fun getPosition(guid: String): Int {
+ serversCache.forEachIndexed { index, it ->
+ if (it.guid == guid)
+ return index
+ }
+ return -1
+ }
+
+ /**
+ * Removes duplicate servers.
+ * @return The number of removed servers.
+ */
+ fun removeDuplicateServer(): Int {
+ val serversCacheCopy = mutableListOf>()
+ for (it in serversCache) {
+ val config = MmkvManager.decodeServerConfig(it.guid) ?: continue
+ serversCacheCopy.add(Pair(it.guid, config))
+ }
+
+ val deleteServer = mutableListOf()
+ serversCacheCopy.forEachIndexed { index, it ->
+ val outbound = it.second
+ serversCacheCopy.forEachIndexed { index2, it2 ->
+ if (index2 > index) {
+ val outbound2 = it2.second
+ if (outbound.equals(outbound2) && !deleteServer.contains(it2.first)) {
+ deleteServer.add(it2.first)
+ }
+ }
+ }
+ }
+ for (it in deleteServer) {
+ MmkvManager.removeServer(it)
+ }
+
+ return deleteServer.count()
+ }
+
+ /**
+ * Removes all servers.
+ * @return The number of removed servers.
+ */
+ fun removeAllServer(): Int {
+ val count =
+ if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
+ MmkvManager.removeAllServer()
+ } else {
+ val serversCopy = serversCache.toList()
+ for (item in serversCopy) {
+ MmkvManager.removeServer(item.guid)
+ }
+ serversCache.toList().count()
+ }
+ return count
+ }
+
+ /**
+ * Removes invalid servers.
+ * @return The number of removed servers.
+ */
+ fun removeInvalidServer(): Int {
+ var count = 0
+ if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
+ count += MmkvManager.removeInvalidServer("")
+ } else {
+ val serversCopy = serversCache.toList()
+ for (item in serversCopy) {
+ count += MmkvManager.removeInvalidServer(item.guid)
+ }
+ }
+ return count
+ }
+
+ /**
+ * Sorts servers by their test results.
+ */
+ fun sortByTestResults() {
+ data class ServerDelay(var guid: String, var testDelayMillis: Long)
+
+ val serverDelays = mutableListOf()
+ val serverList = MmkvManager.decodeServerList()
+ serverList.forEach { key ->
+ val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
+ serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
+ }
+ serverDelays.sortBy { it.testDelayMillis }
+
+ serverDelays.forEach {
+ serverList.remove(it.guid)
+ serverList.add(it.guid)
+ }
+
+ MmkvManager.encodeServerList(serverList)
+ }
+
+ /**
+ * Initializes assets.
+ * @param assets The asset manager.
+ */
+ fun initAssets(assets: AssetManager) {
+ viewModelScope.launch(Dispatchers.Default) {
+ SettingsManager.initAssets(getApplication(), assets)
+ }
+ }
+
+ /**
+ * Filters the configuration by a keyword.
+ * @param keyword The keyword to filter by.
+ */
+ fun filterConfig(keyword: String) {
+ if (keyword == keywordFilter) {
+ return
+ }
+ keywordFilter = keyword
+ MmkvManager.encodeSettings(AppConfig.CACHE_KEYWORD_FILTER, keywordFilter)
+ reloadServerList()
+ }
+
+ private val mMsgReceiver = object : BroadcastReceiver() {
+ override fun onReceive(ctx: Context?, intent: Intent?) {
+ when (intent?.getIntExtra("key", 0)) {
+ AppConfig.MSG_STATE_RUNNING -> {
+ isRunning.value = true
+ }
+
+ AppConfig.MSG_STATE_NOT_RUNNING -> {
+ isRunning.value = false
+ }
+
+ AppConfig.MSG_STATE_START_SUCCESS -> {
+ getApplication().toastSuccess(R.string.toast_services_success)
+ isRunning.value = true
+ }
+
+ AppConfig.MSG_STATE_START_FAILURE -> {
+ getApplication().toastError(R.string.toast_services_failure)
+ isRunning.value = false
+ }
+
+ AppConfig.MSG_STATE_STOP_SUCCESS -> {
+ isRunning.value = false
+ }
+
+ AppConfig.MSG_MEASURE_DELAY_SUCCESS -> {
+ updateTestResultAction.value = intent.getStringExtra("content")
+ }
+
+ AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> {
+ val resultPair = intent.serializable>("content") ?: return
+ MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
+ updateListAction.value = getPosition(resultPair.first)
+ }
+ }
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SettingsViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SettingsViewModel.kt
new file mode 100644
index 00000000..7ac5d60f
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SettingsViewModel.kt
@@ -0,0 +1,99 @@
+package com.v2ray.ang.viewmodel
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.lifecycle.AndroidViewModel
+import androidx.preference.PreferenceManager
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.handler.SettingsManager
+
+class SettingsViewModel(application: Application) : AndroidViewModel(application),
+ SharedPreferences.OnSharedPreferenceChangeListener {
+
+ /**
+ * Starts listening for preference changes.
+ */
+ fun startListenPreferenceChange() {
+ PreferenceManager.getDefaultSharedPreferences(getApplication())
+ .registerOnSharedPreferenceChangeListener(this)
+ }
+
+ /**
+ * Called when the ViewModel is cleared.
+ */
+ override fun onCleared() {
+ PreferenceManager.getDefaultSharedPreferences(getApplication())
+ .unregisterOnSharedPreferenceChangeListener(this)
+ Log.i(AppConfig.TAG, "Settings ViewModel is cleared")
+ super.onCleared()
+ }
+
+ /**
+ * Called when a shared preference is changed.
+ * @param sharedPreferences The shared preferences.
+ * @param key The key of the changed preference.
+ */
+ override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) {
+ Log.i(AppConfig.TAG, "Observe settings changed: $key")
+ when (key) {
+ AppConfig.PREF_MODE,
+ AppConfig.PREF_VPN_DNS,
+ AppConfig.PREF_VPN_BYPASS_LAN,
+ AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX,
+ AppConfig.PREF_REMOTE_DNS,
+ AppConfig.PREF_DOMESTIC_DNS,
+ AppConfig.PREF_DNS_HOSTS,
+ AppConfig.PREF_DELAY_TEST_URL,
+ AppConfig.PREF_LOCAL_DNS_PORT,
+ AppConfig.PREF_SOCKS_PORT,
+ AppConfig.PREF_LOGLEVEL,
+ AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD,
+ AppConfig.PREF_LANGUAGE,
+ AppConfig.PREF_UI_MODE_NIGHT,
+ AppConfig.PREF_ROUTING_DOMAIN_STRATEGY,
+ AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL,
+ AppConfig.PREF_FRAGMENT_PACKETS,
+ AppConfig.PREF_FRAGMENT_LENGTH,
+ AppConfig.PREF_FRAGMENT_INTERVAL,
+ AppConfig.PREF_MUX_XUDP_QUIC,
+ -> {
+ MmkvManager.encodeSettings(key, sharedPreferences.getString(key, ""))
+ }
+
+ AppConfig.PREF_ROUTE_ONLY_ENABLED,
+ AppConfig.PREF_IS_BOOTED,
+ AppConfig.PREF_SPEED_ENABLED,
+ AppConfig.PREF_PROXY_SHARING,
+ AppConfig.PREF_LOCAL_DNS_ENABLED,
+ AppConfig.PREF_FAKE_DNS_ENABLED,
+ AppConfig.PREF_APPEND_HTTP_PROXY,
+ AppConfig.PREF_ALLOW_INSECURE,
+ AppConfig.PREF_PREFER_IPV6,
+ AppConfig.PREF_PER_APP_PROXY,
+ AppConfig.PREF_BYPASS_APPS,
+ AppConfig.PREF_CONFIRM_REMOVE,
+ AppConfig.PREF_START_SCAN_IMMEDIATE,
+ AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
+ AppConfig.SUBSCRIPTION_AUTO_UPDATE,
+ AppConfig.PREF_FRAGMENT_ENABLED,
+ AppConfig.PREF_MUX_ENABLED,
+ -> {
+ MmkvManager.encodeSettings(key, sharedPreferences.getBoolean(key, false))
+ }
+
+ AppConfig.PREF_SNIFFING_ENABLED -> {
+ MmkvManager.encodeSettings(key, sharedPreferences.getBoolean(key, true))
+ }
+
+ AppConfig.PREF_MUX_CONCURRENCY,
+ AppConfig.PREF_MUX_XUDP_CONCURRENCY -> {
+ MmkvManager.encodeSettings(key, sharedPreferences.getString(key, "8"))
+ }
+ }
+ if (key == AppConfig.PREF_UI_MODE_NIGHT) {
+ SettingsManager.setNightMode()
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/jniLibs/arm64-v8a/libtun2socks.so b/V2rayNG/app/src/main/jniLibs/arm64-v8a/libtun2socks.so
deleted file mode 100755
index 0bbc6891..00000000
Binary files a/V2rayNG/app/src/main/jniLibs/arm64-v8a/libtun2socks.so and /dev/null differ
diff --git a/V2rayNG/app/src/main/jniLibs/armeabi-v7a/libtun2socks.so b/V2rayNG/app/src/main/jniLibs/armeabi-v7a/libtun2socks.so
deleted file mode 100755
index 4f588c79..00000000
Binary files a/V2rayNG/app/src/main/jniLibs/armeabi-v7a/libtun2socks.so and /dev/null differ
diff --git a/V2rayNG/app/src/main/jniLibs/x86/libtun2socks.so b/V2rayNG/app/src/main/jniLibs/x86/libtun2socks.so
deleted file mode 100755
index 9ce3e627..00000000
Binary files a/V2rayNG/app/src/main/jniLibs/x86/libtun2socks.so and /dev/null differ
diff --git a/V2rayNG/app/src/main/jniLibs/x86_64/libtun2socks.so b/V2rayNG/app/src/main/jniLibs/x86_64/libtun2socks.so
deleted file mode 100755
index 58199692..00000000
Binary files a/V2rayNG/app/src/main/jniLibs/x86_64/libtun2socks.so and /dev/null differ
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt
deleted file mode 100644
index 6ca82ded..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AngApplication.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.v2ray.ang
-
-import androidx.multidex.MultiDexApplication
-import androidx.preference.PreferenceManager
-import com.tencent.mmkv.MMKV
-
-class AngApplication : MultiDexApplication() {
- companion object {
- const val PREF_LAST_VERSION = "pref_last_version"
- }
-
- var curIndex = -1 //Current proxy that is opened. (Used to implement restart feature)
- var firstRun = false
- private set
-
- override fun onCreate() {
- super.onCreate()
-
-// LeakCanary.install(this)
-
- val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
- firstRun = defaultSharedPreferences.getInt(PREF_LAST_VERSION, 0) != BuildConfig.VERSION_CODE
- if (firstRun)
- defaultSharedPreferences.edit().putInt(PREF_LAST_VERSION, BuildConfig.VERSION_CODE).apply()
-
- //Logger.init().logLevel(if (BuildConfig.DEBUG) LogLevel.FULL else LogLevel.NONE)
- MMKV.initialize(this)
- }
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt
deleted file mode 100644
index 5f37e276..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/AppConfig.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.v2ray.ang
-
-/**
- *
- * App Config Const
- */
-object AppConfig {
- const val ANG_PACKAGE = "com.v2ray.ang"
-
- // legacy
- const val ANG_CONFIG = "ang_config"
- const val PREF_INAPP_BUY_IS_PREMIUM = "pref_inapp_buy_is_premium"
- const val PREF_ROUTING_CUSTOM = "pref_routing_custom"
-
- // Preferences mapped to MMKV
- const val PREF_MODE = "pref_mode"
- const val PREF_SPEED_ENABLED = "pref_speed_enabled"
- const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
- const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
- const val PREF_LOCAL_DNS_ENABLED = "pref_local_dns_enabled"
- const val PREF_FAKE_DNS_ENABLED = "pref_fake_dns_enabled"
- const val PREF_VPN_DNS = "pref_vpn_dns"
- const val PREF_REMOTE_DNS = "pref_remote_dns"
- const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
- const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
- const val PREF_FORWARD_IPV6 = "pref_forward_ipv6"
- const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
- const val PREF_ROUTING_MODE = "pref_routing_mode"
- const val PREF_V2RAY_ROUTING_AGENT = "pref_v2ray_routing_agent"
- const val PREF_V2RAY_ROUTING_DIRECT = "pref_v2ray_routing_direct"
- const val PREF_V2RAY_ROUTING_BLOCKED = "pref_v2ray_routing_blocked"
- const val PREF_PER_APP_PROXY = "pref_per_app_proxy"
- const val PREF_PER_APP_PROXY_SET = "pref_per_app_proxy_set"
- const val PREF_BYPASS_APPS = "pref_bypass_apps"
- // const val PREF_BYPASS_MAINLAND = "pref_bypass_mainland"
- // const val PREF_START_ON_BOOT = "pref_start_on_boot"
- // const val PREF_MUX_ENAimport libv2ray.Libv2rayBLED = "pref_mux_enabled"
- // const val PREF_SOCKS_PORT = "pref_socks_port"
-// const val PREF_HTTP_PORT = "pref_http_port"
- // const val PREF_DONATE = "pref_donate"
- // const val PREF_LICENSES = "pref_licenses"
-// const val PREF_FEEDBACK = "pref_feedback"
-// const val PREF_TG_GROUP = "pref_tg_group"
- // const val PREF_AUTO_RESTART = "pref_auto_restart"
-
- const val HTTP_PROTOCOL: String = "http://"
- const val HTTPS_PROTOCOL: String = "https://"
-
- const val BROADCAST_ACTION_SERVICE = "com.v2ray.ang.action.service"
- const val BROADCAST_ACTION_ACTIVITY = "com.v2ray.ang.action.activity"
- const val BROADCAST_ACTION_WIDGET_CLICK = "com.v2ray.ang.action.widget.click"
-
- const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
- const val TASKER_EXTRA_STRING_BLURB = "com.twofortyfouram.locale.intent.extra.BLURB"
- const val TASKER_EXTRA_BUNDLE_SWITCH = "tasker_extra_bundle_switch"
- const val TASKER_EXTRA_BUNDLE_GUID = "tasker_extra_bundle_guid"
- const val TASKER_DEFAULT_GUID = "Default"
-
- const val TAG_AGENT = "proxy"
- const val TAG_DIRECT = "direct"
- const val TAG_BLOCKED = "block"
-
- const val androidpackagenamelistUrl = "https://raw.githubusercontent.com/2dust/androidpackagenamelist/master/proxy.txt"
- const val v2rayCustomRoutingListUrl = "https://raw.githubusercontent.com/2dust/v2rayCustomRoutingList/master/"
- const val v2rayNGIssues = "https://github.com/2dust/v2rayNG/issues"
- const val v2rayNGWikiMode = "https://github.com/2dust/v2rayNG/wiki/Mode"
- const val promotionUrl = "https://1.2345345.xyz/ads.html"
-
- const val DNS_AGENT = "1.1.1.1"
- const val DNS_DIRECT = "223.5.5.5"
-
- const val MSG_REGISTER_CLIENT = 1
- const val MSG_STATE_RUNNING = 11
- const val MSG_STATE_NOT_RUNNING = 12
- const val MSG_UNREGISTER_CLIENT = 2
- const val MSG_STATE_START = 3
- const val MSG_STATE_START_SUCCESS = 31
- const val MSG_STATE_START_FAILURE = 32
- const val MSG_STATE_STOP = 4
- const val MSG_STATE_STOP_SUCCESS = 41
- const val MSG_STATE_RESTART = 5
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AngConfig.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AngConfig.kt
deleted file mode 100644
index c51d78b6..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AngConfig.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.v2ray.ang.dto
-
-data class AngConfig(
- var index: Int,
- var vmess: ArrayList,
- var subItem: ArrayList
-) {
- data class VmessBean(var guid: String = "123456",
- var address: String = "v2ray.cool",
- var port: Int = 10086,
- var id: String = "a3482e88-686a-4a58-8126-99c9df64b7bf",
- var alterId: Int = 64,
- var security: String = "aes-128-cfb",
- var network: String = "tcp",
- var remarks: String = "def",
- var headerType: String = "",
- var requestHost: String = "",
- var path: String = "",
- var streamSecurity: String = "",
- var configType: Int = 1,
- var configVersion: Int = 1,
- var testResult: String = "",
- var subid: String = "")
-
- data class SubItemBean(var id: String = "",
- var remarks: String = "",
- var url: String = "")
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AppInfo.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AppInfo.kt
deleted file mode 100644
index f99655a8..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/AppInfo.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.v2ray.ang.dto
-
-import android.graphics.drawable.Drawable
-
-data class AppInfo(val appName: String,
- val packageName: String,
- val appIcon: Drawable,
- val isSystemApp: Boolean,
- var isSelected: Int)
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt
deleted file mode 100644
index 3f12160f..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/EConfigType.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.v2ray.ang.dto
-
-enum class EConfigType(val value: Int, val protocolScheme: String) {
- VMESS(1, "vmess://"),
- CUSTOM(2, ""),
- SHADOWSOCKS(3, "ss://"),
- SOCKS(4, "socks://"),
- VLESS(5, "vless://"),
- TROJAN(6, "trojan://");
-
- companion object {
- fun fromInt(value: Int) = values().firstOrNull { it.value == value }
- }
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerConfig.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerConfig.kt
deleted file mode 100644
index db89c946..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/ServerConfig.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package com.v2ray.ang.dto
-
-import com.v2ray.ang.AppConfig.TAG_AGENT
-import com.v2ray.ang.AppConfig.TAG_BLOCKED
-import com.v2ray.ang.AppConfig.TAG_DIRECT
-import com.v2ray.ang.util.Utils
-
-data class ServerConfig(
- val configVersion: Int = 3,
- val configType: EConfigType,
- var subscriptionId: String = "",
- val addedTime: Long = System.currentTimeMillis(),
- var remarks: String = "",
- val outboundBean: V2rayConfig.OutboundBean? = null,
- var fullConfig: V2rayConfig? = null
-) {
- companion object {
- fun create(configType: EConfigType): ServerConfig {
- when(configType) {
- EConfigType.VMESS, EConfigType.VLESS ->
- return ServerConfig(
- configType = configType,
- outboundBean = V2rayConfig.OutboundBean(
- protocol = configType.name.lowercase(),
- settings = V2rayConfig.OutboundBean.OutSettingsBean(
- vnext = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean(
- users = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean())))),
- streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()))
- EConfigType.CUSTOM ->
- return ServerConfig(configType = configType)
- EConfigType.SHADOWSOCKS, EConfigType.SOCKS, EConfigType.TROJAN ->
- return ServerConfig(
- configType = configType,
- outboundBean = V2rayConfig.OutboundBean(
- protocol = configType.name.lowercase(),
- settings = V2rayConfig.OutboundBean.OutSettingsBean(
- servers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.ServersBean())),
- streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()))
- }
- }
- }
-
- fun getProxyOutbound(): V2rayConfig.OutboundBean? {
- if (configType != EConfigType.CUSTOM) {
- return outboundBean
- }
- return fullConfig?.getProxyOutbound()
- }
-
- fun getAllOutboundTags(): MutableList {
- if (configType != EConfigType.CUSTOM) {
- return mutableListOf(TAG_AGENT, TAG_DIRECT, TAG_BLOCKED)
- }
- fullConfig?.let { config ->
- return config.outbounds.map { it.tag }.toMutableList()
- }
- return mutableListOf()
- }
-
- fun getV2rayPointDomainAndPort(): String {
- val address = getProxyOutbound()?.getServerAddress().orEmpty()
- val port = getProxyOutbound()?.getServerPort()
- return if (Utils.isIpv6Address(address)) {
- String.format("[%s]:%s", address, port)
- } else {
- String.format("%s:%s", address, port)
- }
- }
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt
deleted file mode 100644
index b2195148..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/SubscriptionItem.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.v2ray.ang.dto
-
-data class SubscriptionItem(
- var remarks: String = "",
- var url: String = "",
- var enabled: Boolean = true,
- val addedTime: Long = System.currentTimeMillis()) {
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt
deleted file mode 100644
index 734cf876..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/V2rayConfig.kt
+++ /dev/null
@@ -1,447 +0,0 @@
-package com.v2ray.ang.dto
-
-import android.text.TextUtils
-import com.google.gson.GsonBuilder
-import com.google.gson.JsonPrimitive
-import com.google.gson.JsonSerializationContext
-import com.google.gson.JsonSerializer
-import com.google.gson.annotations.SerializedName
-import com.google.gson.reflect.TypeToken
-import java.lang.reflect.Type
-
-data class V2rayConfig(
- var stats: Any? = null,
- val log: LogBean,
- var policy: PolicyBean?,
- val inbounds: ArrayList,
- var outbounds: ArrayList,
- var dns: DnsBean,
- val routing: RoutingBean,
- val api: Any? = null,
- val transport: Any? = null,
- val reverse: Any? = null,
- var fakedns: Any? = null,
- val browserForwarder: Any? = null) {
- companion object {
- const val DEFAULT_PORT = 443
- const val DEFAULT_SECURITY = "auto"
- const val DEFAULT_LEVEL = 8
- const val DEFAULT_NETWORK = "tcp"
- const val DEFAULT_FLOW = "xtls-rprx-splice"
-
- const val TLS = "tls"
- const val XTLS = "xtls"
- const val HTTP = "http"
- }
-
- data class LogBean(val access: String,
- val error: String,
- var loglevel: String?,
- val dnsLog: Boolean? = null)
-
- data class InboundBean(
- var tag: String,
- var port: Int,
- var protocol: String,
- var listen: String? = null,
- val settings: Any? = null,
- val sniffing: SniffingBean?,
- val streamSettings: Any? = null,
- val allocate: Any? = null) {
-
- data class InSettingsBean(val auth: String? = null,
- val udp: Boolean? = null,
- val userLevel: Int? = null,
- val address: String? = null,
- val port: Int? = null,
- val network: String? = null)
-
- data class SniffingBean(var enabled: Boolean,
- val destOverride: ArrayList,
- val metadataOnly: Boolean? = null)
- }
-
- data class OutboundBean(val tag: String = "proxy",
- var protocol: String,
- var settings: OutSettingsBean? = null,
- var streamSettings: StreamSettingsBean? = null,
- val proxySettings: Any? = null,
- val sendThrough: String? = null,
- val mux: MuxBean? = MuxBean(false)) {
-
- data class OutSettingsBean(var vnext: List? = null,
- var servers: List? = null,
- /*Blackhole*/
- var response: Response? = null,
- /*DNS*/
- val network: String? = null,
- val address: String? = null,
- val port: Int? = null,
- /*Freedom*/
- var domainStrategy: String? = null,
- val redirect: String? = null,
- val userLevel: Int? = null,
- /*Loopback*/
- val inboundTag: String? = null) {
-
- data class VnextBean(var address: String = "",
- var port: Int = DEFAULT_PORT,
- var users: List) {
-
- data class UsersBean(var id: String = "",
- var alterId: Int? = null,
- var security: String = DEFAULT_SECURITY,
- var level: Int = DEFAULT_LEVEL,
- var encryption: String = "",
- var flow: String = "")
- }
-
- data class ServersBean(var address: String = "",
- var method: String = "chacha20-poly1305",
- var ota: Boolean = false,
- var password: String = "",
- var port: Int = DEFAULT_PORT,
- var level: Int = DEFAULT_LEVEL,
- val email: String? = null,
- val flow: String? = null,
- val ivCheck: Boolean? = null,
- var users: List? = null) {
-
-
- data class SocksUsersBean(var user: String = "",
- var pass: String = "",
- var level: Int = DEFAULT_LEVEL)
- }
-
- data class Response(var type: String)
- }
-
- data class StreamSettingsBean(var network: String = DEFAULT_NETWORK,
- var security: String = "",
- var tcpSettings: TcpSettingsBean? = null,
- var kcpSettings: KcpSettingsBean? = null,
- var wsSettings: WsSettingsBean? = null,
- var httpSettings: HttpSettingsBean? = null,
- var tlsSettings: TlsSettingsBean? = null,
- var quicSettings: QuicSettingBean? = null,
- var xtlsSettings: TlsSettingsBean? = null,
- var grpcSettings: GrpcSettingsBean? = null,
- val dsSettings: Any? = null,
- val sockopt: Any? = null
- ) {
-
- data class TcpSettingsBean(var header: HeaderBean = HeaderBean(),
- val acceptProxyProtocol: Boolean? = null) {
- data class HeaderBean(var type: String = "none",
- var request: RequestBean? = null,
- var response: Any? = null) {
- data class RequestBean(var path: List = ArrayList(),
- var headers: HeadersBean = HeadersBean(),
- val version: String? = null,
- val method: String? = null) {
- data class HeadersBean(var Host: List = ArrayList(),
- @SerializedName("User-Agent")
- val userAgent: List? = null,
- @SerializedName("Accept-Encoding")
- val acceptEncoding: List? = null,
- val Connection: List? = null,
- val Pragma: String? = null)
- }
- }
- }
-
- data class KcpSettingsBean(var mtu: Int = 1350,
- var tti: Int = 50,
- var uplinkCapacity: Int = 12,
- var downlinkCapacity: Int = 100,
- var congestion: Boolean = false,
- var readBufferSize: Int = 1,
- var writeBufferSize: Int = 1,
- var header: HeaderBean = HeaderBean(),
- var seed: String? = null) {
- data class HeaderBean(var type: String = "none")
- }
-
- data class WsSettingsBean(var path: String = "",
- var headers: HeadersBean = HeadersBean(),
- val maxEarlyData: Int? = null,
- val useBrowserForwarding: Boolean? = null,
- val acceptProxyProtocol: Boolean? = null) {
- data class HeadersBean(var Host: String = "")
- }
-
- data class HttpSettingsBean(var host: List = ArrayList(),
- var path: String = "")
-
- data class TlsSettingsBean(var allowInsecure: Boolean = false,
- var serverName: String = "",
- val alpn: List? = null,
- val minVersion: String? = null,
- val maxVersion: String? = null,
- val preferServerCipherSuites: Boolean? = null,
- val cipherSuites: String? = null,
- val fingerprint: String? = null,
- val certificates: List? = null,
- val disableSystemRoot: Boolean? = null,
- val enableSessionResumption: Boolean? = null)
-
- data class QuicSettingBean(var security: String = "none",
- var key: String = "",
- var header: HeaderBean = HeaderBean()) {
- data class HeaderBean(var type: String = "none")
- }
-
- data class GrpcSettingsBean(var serviceName: String = "",
- var multiMode: Boolean? = null)
-
- fun populateTransportSettings(transport: String, headerType: String?, host: String?, path: String?, seed: String?,
- quicSecurity: String?, key: String?, mode: String?, serviceName: String?): String {
- var sni = ""
- network = transport
- when (network) {
- "tcp" -> {
- val tcpSetting = TcpSettingsBean()
- if (headerType == HTTP) {
- tcpSetting.header.type = HTTP
- if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
- val requestObj = TcpSettingsBean.HeaderBean.RequestBean()
- requestObj.headers.Host = (host ?: "").split(",").map { it.trim() }.filter { it.isNotEmpty() }
- requestObj.path = (path ?: "").split(",").map { it.trim() }.filter { it.isNotEmpty() }
- tcpSetting.header.request = requestObj
- sni = requestObj.headers.Host.getOrNull(0) ?: sni
- }
- } else {
- tcpSetting.header.type = "none"
- sni = host ?: ""
- }
- tcpSettings = tcpSetting
- }
- "kcp" -> {
- val kcpsetting = KcpSettingsBean()
- kcpsetting.header.type = headerType ?: "none"
- if (seed.isNullOrEmpty()) {
- kcpsetting.seed = null
- } else {
- kcpsetting.seed = seed
- }
- kcpSettings = kcpsetting
- }
- "ws" -> {
- val wssetting = WsSettingsBean()
- wssetting.headers.Host = host ?: ""
- sni = wssetting.headers.Host
- wssetting.path = path ?: "/"
- wsSettings = wssetting
- }
- "h2", "http" -> {
- network = "h2"
- val h2Setting = HttpSettingsBean()
- h2Setting.host = (host ?: "").split(",").map { it.trim() }.filter { it.isNotEmpty() }
- sni = h2Setting.host.getOrNull(0) ?: sni
- h2Setting.path = path ?: "/"
- httpSettings = h2Setting
- }
- "quic" -> {
- val quicsetting = QuicSettingBean()
- quicsetting.security = quicSecurity ?: "none"
- quicsetting.key = key ?: ""
- quicsetting.header.type = headerType ?: "none"
- quicSettings = quicsetting
- }
- "grpc" -> {
- val grpcSetting = GrpcSettingsBean()
- grpcSetting.multiMode = mode == "multi"
- grpcSetting.serviceName = serviceName ?: ""
- sni = host ?: ""
- grpcSettings = grpcSetting
- }
- }
- return sni
- }
-
- fun populateTlsSettings(streamSecurity: String, allowInsecure: Boolean, sni: String) {
- security = streamSecurity
- val tlsSetting = TlsSettingsBean(
- allowInsecure = allowInsecure,
- serverName = sni
- )
- if (security == TLS) {
- tlsSettings = tlsSetting
- } else if (security == XTLS) {
- xtlsSettings = tlsSetting
- }
- }
- }
-
- data class MuxBean(var enabled: Boolean, var concurrency: Int = 8)
-
- fun getServerAddress(): String? {
- if (protocol.equals(EConfigType.VMESS.name, true)
- || protocol.equals(EConfigType.VLESS.name, true)) {
- return settings?.vnext?.get(0)?.address
- } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
- || protocol.equals(EConfigType.SOCKS.name, true)
- || protocol.equals(EConfigType.TROJAN.name, true)) {
- return settings?.servers?.get(0)?.address
- }
- return null
- }
-
- fun getServerPort(): Int? {
- if (protocol.equals(EConfigType.VMESS.name, true)
- || protocol.equals(EConfigType.VLESS.name, true)) {
- return settings?.vnext?.get(0)?.port
- } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
- || protocol.equals(EConfigType.SOCKS.name, true)
- || protocol.equals(EConfigType.TROJAN.name, true)) {
- return settings?.servers?.get(0)?.port
- }
- return null
- }
-
- fun getPassword(): String? {
- if (protocol.equals(EConfigType.VMESS.name, true)
- || protocol.equals(EConfigType.VLESS.name, true)) {
- return settings?.vnext?.get(0)?.users?.get(0)?.id
- } else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
- || protocol.equals(EConfigType.TROJAN.name, true)) {
- return settings?.servers?.get(0)?.password
- } else if (protocol.equals(EConfigType.SOCKS.name, true)) {
- return settings?.servers?.get(0)?.users?.get(0)?.pass
- }
- return null
- }
-
- fun getSecurityEncryption(): String? {
- return when {
- protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.get(0)?.users?.get(0)?.security
- protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.get(0)?.users?.get(0)?.encryption
- protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.get(0)?.method
- else -> null
- }
- }
-
- fun getTransportSettingDetails(): List? {
- if (protocol.equals(EConfigType.VMESS.name, true)
- || protocol.equals(EConfigType.VLESS.name, true)) {
- val transport = streamSettings?.network ?: return null
- return when (transport) {
- "tcp" -> {
- val tcpSetting = streamSettings?.tcpSettings ?: return null
- listOf(tcpSetting.header.type,
- tcpSetting.header.request?.headers?.Host?.joinToString().orEmpty(),
- tcpSetting.header.request?.path?.joinToString().orEmpty())
- }
- "kcp" -> {
- val kcpSetting = streamSettings?.kcpSettings ?: return null
- listOf(kcpSetting.header.type,
- "",
- kcpSetting.seed.orEmpty())
- }
- "ws" -> {
- val wsSetting = streamSettings?.wsSettings ?: return null
- listOf("",
- wsSetting.headers.Host,
- wsSetting.path)
- }
- "h2" -> {
- 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)
- }
- "grpc" -> {
- val grpcSetting = streamSettings?.grpcSettings ?: return null
- listOf(if (grpcSetting.multiMode == true) "multi" else "gun",
- "",
- grpcSetting.serviceName)
- }
- else -> null
- }
- }
- return null
- }
- }
-
- data class DnsBean(var servers: ArrayList? = null,
- var hosts: Map? = null,
- val clientIp: String? = null,
- val disableCache: Boolean? = null,
- val queryStrategy: String? = null,
- val tag: String? = null
- ) {
- data class ServersBean(var address: String = "",
- var port: Int? = null,
- var domains: List? = null,
- var expectIPs: List? = null,
- val clientIp: String? = null)
- }
-
- data class RoutingBean(var domainStrategy: String,
- val domainMatcher: String? = null,
- var rules: ArrayList,
- val balancers: List? = null) {
-
- data class RulesBean(var type: String = "",
- var ip: ArrayList? = null,
- var domain: ArrayList? = null,
- var outboundTag: String = "",
- var balancerTag: String? = null,
- var port: String? = null,
- val sourcePort: String? = null,
- val network: String? = null,
- val source: List? = null,
- val user: List? = null,
- var inboundTag: List? = null,
- val protocol: List? = null,
- val attrs: String? = null,
- val domainMatcher: String? = null
- )
- }
-
- data class PolicyBean(var levels: Map,
- var system: Any? = null) {
- data class LevelBean(
- var handshake: Int? = null,
- var connIdle: Int? = null,
- var uplinkOnly: Int? = null,
- var downlinkOnly: Int? = null,
- val statsUserUplink: Boolean? = null,
- val statsUserDownlink: Boolean? = null,
- var bufferSize: Int? = null)
- }
-
- data class FakednsBean(var ipPool: String = "198.18.0.0/15",
- var poolSize: Int = 10000) // roughly 10 times smaller than total ip pool
-
- fun getProxyOutbound(): OutboundBean? {
- outbounds.forEach { outbound ->
- if (outbound.protocol.equals(EConfigType.VMESS.name, true) ||
- outbound.protocol.equals(EConfigType.VLESS.name, true) ||
- outbound.protocol.equals(EConfigType.SHADOWSOCKS.name, true) ||
- outbound.protocol.equals(EConfigType.SOCKS.name, true) ||
- outbound.protocol.equals(EConfigType.TROJAN.name, true)) {
- return outbound
- }
- }
- return null
- }
-
- fun toPrettyPrinting(): String {
- return GsonBuilder()
- .setPrettyPrinting()
- .disableHtmlEscaping()
- .registerTypeAdapter( // custom serialiser is needed here since JSON by default parse number as Double, core will fail to start
- object : TypeToken() {}.type,
- JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? -> JsonPrimitive(src?.toInt()) }
- )
- .create()
- .toJson(this)
- }
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/VmessQRCode.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/VmessQRCode.kt
deleted file mode 100644
index d8363ca5..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/dto/VmessQRCode.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.v2ray.ang.dto
-
-data class VmessQRCode(var v: String = "",
- var ps: String = "",
- var add: String = "",
- var port: String = "",
- var id: String = "",
- var aid: String = "",
- var net: String = "",
- var type: String = "",
- var host: String = "",
- var path: String = "",
- var tls: String = "",
- var sni: String = "")
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt
deleted file mode 100644
index 4d317e67..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/extension/_Ext.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package com.v2ray.ang.extension
-
-import android.content.Context
-import android.os.Build
-import android.widget.Toast
-import com.v2ray.ang.AngApplication
-import me.drakeet.support.toast.ToastCompat
-import org.json.JSONObject
-import java.net.URLConnection
-
-/**
- * Some extensions
- */
-
-val Context.v2RayApplication: AngApplication
- get() = applicationContext as AngApplication
-
-inline fun Context.toast(message: Int): Toast = ToastCompat
- .makeText(this, message, Toast.LENGTH_SHORT)
- .apply {
- show()
- }
-
-inline fun Context.toast(message: CharSequence): Toast = ToastCompat
- .makeText(this, message, Toast.LENGTH_SHORT)
- .apply {
- show()
- }
-
-fun JSONObject.putOpt(pair: Pair) = putOpt(pair.first, pair.second)
-fun JSONObject.putOpt(pairs: Map) = pairs.forEach { putOpt(it.key to it.value) }
-
-const val threshold = 1000
-const val divisor = 1024F
-
-fun Long.toSpeedString() = toTrafficString() + "/s"
-
-fun Long.toTrafficString(): String {
- if (this == 0L)
- return "\t\t\t0\t B"
-
- if (this < threshold)
- return "${this.toFloat().toShortString()}\t B"
-
- val kib = this / divisor
- if (kib < threshold)
- return "${kib.toShortString()}\t KB"
-
- val mib = kib / divisor
- if (mib < threshold)
- return "${mib.toShortString()}\t MB"
-
- val gib = mib / divisor
- if (gib < threshold)
- return "${gib.toShortString()}\t GB"
-
- val tib = gib / divisor
- if (tib < threshold)
- return "${tib.toShortString()}\t TB"
-
- val pib = tib / divisor
- if (pib < threshold)
- return "${pib.toShortString()}\t PB"
-
- return "∞"
-}
-
-private fun Float.toShortString(): String {
- val s = "%.2f".format(this)
- if (s.length <= 4)
- return s
- return s.substring(0, 4).removeSuffix(".")
-}
-
-val URLConnection.responseLength: Long
- get() = if (Build.VERSION.SDK_INT >= 24) contentLengthLong else contentLength.toLong()
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt
deleted file mode 100644
index 20259d4e..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/receiver/WidgetProvider.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.v2ray.ang.receiver
-
-import android.app.PendingIntent
-import android.appwidget.AppWidgetManager
-import android.appwidget.AppWidgetProvider
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.widget.RemoteViews
-import com.v2ray.ang.R
-import com.v2ray.ang.AppConfig
-import com.v2ray.ang.service.V2RayServiceManager
-import com.v2ray.ang.util.Utils
-
-class WidgetProvider : AppWidgetProvider() {
- /**
- * 每次窗口小部件被更新都调用一次该方法
- */
- override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
- super.onUpdate(context, appWidgetManager, appWidgetIds)
- updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.v2rayPoint.isRunning)
- }
-
- private fun updateWidgetBackground(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, isRunning: Boolean) {
- val remoteViews = RemoteViews(context.packageName, R.layout.widget_switch)
- val intent = Intent(context, WidgetProvider::class.java)
- intent.action = AppConfig.BROADCAST_ACTION_WIDGET_CLICK
- val pendingIntent = PendingIntent.getBroadcast(context, R.id.layout_switch, intent, PendingIntent.FLAG_UPDATE_CURRENT)
- remoteViews.setOnClickPendingIntent(R.id.layout_switch, pendingIntent)
- if (isRunning) {
- remoteViews.setInt(R.id.layout_switch, "setBackgroundResource", R.drawable.ic_rounded_corner_theme)
- } else {
- remoteViews.setInt(R.id.layout_switch, "setBackgroundResource", R.drawable.ic_rounded_corner_grey)
- }
-
- for (appWidgetId in appWidgetIds) {
- appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
- }
- }
-
- /**
- * 接收窗口小部件发送的广播
- */
- override fun onReceive(context: Context, intent: Intent) {
- super.onReceive(context, intent)
- if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
- if (V2RayServiceManager.v2rayPoint.isRunning) {
- Utils.stopVService(context)
- } else {
- Utils.startVServiceFromToggle(context)
- }
- } else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
- AppWidgetManager.getInstance(context)?.let { manager ->
- when (intent.getIntExtra("key", 0)) {
- AppConfig.MSG_STATE_RUNNING, AppConfig.MSG_STATE_START_SUCCESS -> {
- updateWidgetBackground(context, manager, manager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)),
- true)
- }
- AppConfig.MSG_STATE_NOT_RUNNING, AppConfig.MSG_STATE_START_FAILURE, AppConfig.MSG_STATE_STOP_SUCCESS -> {
- updateWidgetBackground(context, manager, manager.getAppWidgetIds(ComponentName(context, WidgetProvider::class.java)),
- false)
- }
- }
- }
- }
- }
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt
deleted file mode 100644
index 2998342a..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/ServiceControl.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.v2ray.ang.service
-
-import android.app.Service
-
-interface ServiceControl {
- fun getService(): Service
-
- fun startService(parameters: String)
-
- fun stopService()
-
- fun vpnProtect(socket: Int): Boolean
-
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt
deleted file mode 100644
index 4e4db1fb..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayProxyOnlyService.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.v2ray.ang.service
-
-import android.app.Service
-import android.content.Intent
-import android.os.IBinder
-import java.lang.ref.SoftReference
-
-class V2RayProxyOnlyService : Service(), ServiceControl {
- override fun onCreate() {
- super.onCreate()
- V2RayServiceManager.serviceControl = SoftReference(this)
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- V2RayServiceManager.startV2rayPoint()
- return START_STICKY
- }
-
- override fun onDestroy() {
- super.onDestroy()
- V2RayServiceManager.stopV2rayPoint()
- }
-
- override fun getService(): Service {
- return this
- }
-
- override fun startService(parameters: String) {
- // do nothing
- }
-
- override fun stopService() {
- stopSelf()
- }
-
- override fun vpnProtect(socket: Int): Boolean {
- return true
- }
-
- override fun onBind(intent: Intent?): IBinder? {
- return null
- }
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt
deleted file mode 100644
index cf44bfa2..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayServiceManager.kt
+++ /dev/null
@@ -1,377 +0,0 @@
-package com.v2ray.ang.service
-
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.graphics.Color
-import android.os.Build
-import androidx.annotation.RequiresApi
-import androidx.core.app.NotificationCompat
-import android.util.Log
-import com.tencent.mmkv.MMKV
-import com.v2ray.ang.AppConfig
-import com.v2ray.ang.AppConfig.ANG_PACKAGE
-import com.v2ray.ang.AppConfig.TAG_DIRECT
-import com.v2ray.ang.R
-import com.v2ray.ang.dto.ServerConfig
-import com.v2ray.ang.extension.toSpeedString
-import com.v2ray.ang.extension.toast
-import com.v2ray.ang.ui.MainActivity
-import com.v2ray.ang.util.MessageUtil
-import com.v2ray.ang.util.MmkvManager
-import com.v2ray.ang.util.Utils
-import com.v2ray.ang.util.V2rayConfigUtil
-import go.Seq
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import libv2ray.Libv2ray
-import libv2ray.V2RayPoint
-import libv2ray.V2RayVPNServiceSupportsSet
-import rx.Observable
-import rx.Subscription
-import java.lang.ref.SoftReference
-import kotlin.math.min
-
-object V2RayServiceManager {
- private const val NOTIFICATION_ID = 1
- private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
- private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
- private const val NOTIFICATION_ICON_THRESHOLD = 3000
-
- val v2rayPoint: V2RayPoint = Libv2ray.newV2RayPoint(V2RayCallback())
- private val mMsgReceive = ReceiveMessageHandler()
- private val mainStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
- private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
-
- var serviceControl: SoftReference? = null
- set(value) {
- field = value
- val context = value?.get()?.getService()?.applicationContext
- context?.let {
- v2rayPoint.packageName = Utils.packagePath(context)
- v2rayPoint.packageCodePath = context.applicationInfo.nativeLibraryDir + "/"
- Seq.setContext(context)
- }
- }
- var currentConfig: ServerConfig? = null
-
- private var lastQueryTime = 0L
- private var mBuilder: NotificationCompat.Builder? = null
- private var mSubscription: Subscription? = null
- private var mNotificationManager: NotificationManager? = null
-
- fun startV2Ray(context: Context) {
- if (settingsStorage?.decodeBool(AppConfig.PREF_PROXY_SHARING) == true) {
- context.toast(R.string.toast_warning_pref_proxysharing_short)
- }else{
- context.toast(R.string.toast_services_start)
- }
- val intent = if (settingsStorage?.decodeString(AppConfig.PREF_MODE) ?: "VPN" == "VPN") {
- Intent(context.applicationContext, V2RayVpnService::class.java)
- } else {
- Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
- }
- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) {
- context.startForegroundService(intent)
- } else {
- context.startService(intent)
- }
- }
-
- private class V2RayCallback : V2RayVPNServiceSupportsSet {
- override fun shutdown(): Long {
- val serviceControl = serviceControl?.get() ?: return -1
- // called by go
- // shutdown the whole vpn service
- return try {
- serviceControl.stopService()
- 0
- } catch (e: Exception) {
- Log.d(ANG_PACKAGE, e.toString())
- -1
- }
- }
-
- override fun prepare(): Long {
- return 0
- }
-
- override fun protect(l: Long): Long {
- val serviceControl = serviceControl?.get() ?: return 0
- return if (serviceControl.vpnProtect(l.toInt())) 0 else 1
- }
-
- override fun onEmitStatus(l: Long, s: String?): Long {
- //Logger.d(s)
- return 0
- }
-
- override fun setup(s: String): Long {
- val serviceControl = serviceControl?.get() ?: return -1
- //Logger.d(s)
- return try {
- serviceControl.startService(s)
- lastQueryTime = System.currentTimeMillis()
- startSpeedNotification()
- 0
- } catch (e: Exception) {
- Log.d(ANG_PACKAGE, e.toString())
- -1
- }
- }
-
- }
-
- fun startV2rayPoint() {
- val service = serviceControl?.get()?.getService() ?: return
- val guid = mainStorage?.decodeString(MmkvManager.KEY_SELECTED_SERVER) ?: return
- val config = MmkvManager.decodeServerConfig(guid) ?: return
- if (!v2rayPoint.isRunning) {
- val result = V2rayConfigUtil.getV2rayConfig(service, guid)
- if (!result.status)
- return
-
- try {
- val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
- mFilter.addAction(Intent.ACTION_SCREEN_ON)
- mFilter.addAction(Intent.ACTION_SCREEN_OFF)
- mFilter.addAction(Intent.ACTION_USER_PRESENT)
- service.registerReceiver(mMsgReceive, mFilter)
- } catch (e: Exception) {
- Log.d(ANG_PACKAGE, e.toString())
- }
-
- v2rayPoint.configureFileContent = result.content
- v2rayPoint.domainName = config.getV2rayPointDomainAndPort()
- currentConfig = config
- v2rayPoint.enableLocalDNS = settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) ?: false
- v2rayPoint.forwardIpv6 = settingsStorage?.decodeBool(AppConfig.PREF_FORWARD_IPV6) ?: false
- v2rayPoint.proxyOnly = settingsStorage?.decodeString(AppConfig.PREF_MODE) ?: "VPN" != "VPN"
-
- try {
- v2rayPoint.runLoop()
- } catch (e: Exception) {
- Log.d(ANG_PACKAGE, e.toString())
- }
-
- if (v2rayPoint.isRunning) {
- MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
- showNotification()
- } else {
- MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
- cancelNotification()
- }
- }
- }
-
- fun stopV2rayPoint() {
- val service = serviceControl?.get()?.getService() ?: return
-
- if (v2rayPoint.isRunning) {
- GlobalScope.launch(Dispatchers.Default) {
- try {
- v2rayPoint.stopLoop()
- } catch (e: Exception) {
- Log.d(ANG_PACKAGE, e.toString())
- }
- }
- }
-
- MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
- cancelNotification()
-
- try {
- service.unregisterReceiver(mMsgReceive)
- } catch (e: Exception) {
- Log.d(ANG_PACKAGE, e.toString())
- }
- }
-
- private class ReceiveMessageHandler : BroadcastReceiver() {
- override fun onReceive(ctx: Context?, intent: Intent?) {
- val serviceControl = serviceControl?.get() ?: return
- when (intent?.getIntExtra("key", 0)) {
- AppConfig.MSG_REGISTER_CLIENT -> {
- //Logger.e("ReceiveMessageHandler", intent?.getIntExtra("key", 0).toString())
- if (v2rayPoint.isRunning) {
- MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
- } else {
- MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
- }
- }
- AppConfig.MSG_UNREGISTER_CLIENT -> {
- // nothing to do
- }
- AppConfig.MSG_STATE_START -> {
- // nothing to do
- }
- AppConfig.MSG_STATE_STOP -> {
- serviceControl.stopService()
- }
- AppConfig.MSG_STATE_RESTART -> {
- startV2rayPoint()
- }
- }
-
- when (intent?.action) {
- Intent.ACTION_SCREEN_OFF -> {
- Log.d(ANG_PACKAGE, "SCREEN_OFF, stop querying stats")
- stopSpeedNotification()
- }
- Intent.ACTION_SCREEN_ON -> {
- Log.d(ANG_PACKAGE, "SCREEN_ON, start querying stats")
- startSpeedNotification()
- }
- }
- }
- }
-
- private fun showNotification() {
- val service = serviceControl?.get()?.getService() ?: return
- val startMainIntent = Intent(service, MainActivity::class.java)
- val contentPendingIntent = PendingIntent.getActivity(service,
- NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent,
- PendingIntent.FLAG_UPDATE_CURRENT)
-
- val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
- stopV2RayIntent.`package` = ANG_PACKAGE
- stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
-
- val stopV2RayPendingIntent = PendingIntent.getBroadcast(service,
- NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent,
- PendingIntent.FLAG_UPDATE_CURRENT)
-
- val channelId =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- createNotificationChannel()
- } else {
- // If earlier version channel ID is not used
- // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
- ""
- }
-
- mBuilder = NotificationCompat.Builder(service, channelId)
- .setSmallIcon(R.drawable.ic_v)
- .setContentTitle(currentConfig?.remarks)
- .setPriority(NotificationCompat.PRIORITY_MIN)
- .setOngoing(true)
- .setShowWhen(false)
- .setOnlyAlertOnce(true)
- .setContentIntent(contentPendingIntent)
- .addAction(R.drawable.ic_close_grey_800_24dp,
- service.getString(R.string.notification_action_stop_v2ray),
- stopV2RayPendingIntent)
- //.build()
-
- //mBuilder?.setDefaults(NotificationCompat.FLAG_ONLY_ALERT_ONCE) //取消震动,铃声其他都不好使
-
- service.startForeground(NOTIFICATION_ID, mBuilder?.build())
- }
-
- @RequiresApi(Build.VERSION_CODES.O)
- private fun createNotificationChannel(): String {
- val channelId = "RAY_NG_M_CH_ID"
- val channelName = "V2rayNG Background Service"
- val chan = NotificationChannel(channelId,
- channelName, NotificationManager.IMPORTANCE_HIGH)
- chan.lightColor = Color.DKGRAY
- chan.importance = NotificationManager.IMPORTANCE_NONE
- chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
- getNotificationManager()?.createNotificationChannel(chan)
- return channelId
- }
-
- fun cancelNotification() {
- val service = serviceControl?.get()?.getService() ?: return
- service.stopForeground(true)
- mBuilder = null
- mSubscription?.unsubscribe()
- mSubscription = null
- }
-
- private fun updateNotification(contentText: String?, proxyTraffic: Long, directTraffic: Long) {
- if (mBuilder != null) {
- if (proxyTraffic < NOTIFICATION_ICON_THRESHOLD && directTraffic < NOTIFICATION_ICON_THRESHOLD) {
- mBuilder?.setSmallIcon(R.drawable.ic_v)
- } else if (proxyTraffic > directTraffic) {
- mBuilder?.setSmallIcon(R.drawable.ic_stat_proxy)
- } else {
- mBuilder?.setSmallIcon(R.drawable.ic_stat_direct)
- }
- mBuilder?.setStyle(NotificationCompat.BigTextStyle().bigText(contentText))
- mBuilder?.setContentText(contentText) // Emui4.1 need content text even if style is set as BigTextStyle
- getNotificationManager()?.notify(NOTIFICATION_ID, mBuilder?.build())
- }
- }
-
- private fun getNotificationManager(): NotificationManager? {
- if (mNotificationManager == null) {
- val service = serviceControl?.get()?.getService() ?: return null
- mNotificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- }
- return mNotificationManager
- }
-
- fun startSpeedNotification() {
- if (mSubscription == null &&
- v2rayPoint.isRunning &&
- settingsStorage?.decodeBool(AppConfig.PREF_SPEED_ENABLED) == true) {
- var lastZeroSpeed = false
- val outboundTags = currentConfig?.getAllOutboundTags()
- outboundTags?.remove(TAG_DIRECT)
-
- mSubscription = Observable.interval(3, java.util.concurrent.TimeUnit.SECONDS)
- .subscribe {
- val queryTime = System.currentTimeMillis()
- val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
- var proxyTotal = 0L
- val text = StringBuilder()
- outboundTags?.forEach {
- val up = v2rayPoint.queryStats(it, "uplink")
- val down = v2rayPoint.queryStats(it, "downlink")
- if (up + down > 0) {
- appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
- proxyTotal += up + down
- }
- }
- val directUplink = v2rayPoint.queryStats(TAG_DIRECT, "uplink")
- val directDownlink = v2rayPoint.queryStats(TAG_DIRECT, "downlink")
- val zeroSpeed = (proxyTotal == 0L && directUplink == 0L && directDownlink == 0L)
- if (!zeroSpeed || !lastZeroSpeed) {
- if (proxyTotal == 0L) {
- appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
- }
- appendSpeedString(text, TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
- directDownlink / sinceLastQueryInSeconds)
- updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
- }
- lastZeroSpeed = zeroSpeed
- lastQueryTime = queryTime
- }
- }
- }
-
- private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
- var n = name ?: "no tag"
- n = n.substring(0, min(n.length, 6))
- text.append(n)
- for (i in n.length..6 step 2) {
- text.append("\t")
- }
- text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
- }
-
- fun stopSpeedNotification() {
- if (mSubscription != null) {
- mSubscription?.unsubscribe() //stop queryStats
- mSubscription = null
- updateNotification(currentConfig?.remarks, 0, 0)
- }
- }
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt
deleted file mode 100644
index 586c5c00..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/service/V2RayVpnService.kt
+++ /dev/null
@@ -1,259 +0,0 @@
-package com.v2ray.ang.service
-
-import android.app.*
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.*
-import android.os.Build
-import android.os.ParcelFileDescriptor
-import android.os.StrictMode
-import androidx.annotation.RequiresApi
-import android.util.Log
-import com.tencent.mmkv.MMKV
-import com.v2ray.ang.AppConfig
-import com.v2ray.ang.R
-import com.v2ray.ang.util.MmkvManager
-import com.v2ray.ang.util.Utils
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import java.io.File
-import java.lang.ref.SoftReference
-
-class V2RayVpnService : VpnService(), ServiceControl {
- private val settingsStorage by lazy { MMKV.mmkvWithID(MmkvManager.ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
-
- private lateinit var mInterface: ParcelFileDescriptor
-
- /**
- * Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
- *
- * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
- * satisfies default network capabilities but only THE default network. Unfortunately we need to have
- * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
- *
- * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
- */
- @delegate:RequiresApi(Build.VERSION_CODES.P)
- private val defaultNetworkRequest by lazy {
- NetworkRequest.Builder()
- .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
- .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
- .build()
- }
-
- private val connectivity by lazy { getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager }
-
- @delegate:RequiresApi(Build.VERSION_CODES.P)
- private val defaultNetworkCallback by lazy {
- object : ConnectivityManager.NetworkCallback() {
- override fun onAvailable(network: Network) {
- setUnderlyingNetworks(arrayOf(network))
- }
- override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
- // it's a good idea to refresh capabilities
- setUnderlyingNetworks(arrayOf(network))
- }
- override fun onLost(network: Network) {
- setUnderlyingNetworks(null)
- }
- }
- }
-
- override fun onCreate() {
- super.onCreate()
-
- val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
- StrictMode.setThreadPolicy(policy)
- V2RayServiceManager.serviceControl = SoftReference(this)
- }
-
- override fun onRevoke() {
- stopV2Ray()
- }
-
- override fun onLowMemory() {
- stopV2Ray()
- super.onLowMemory()
- }
-
- override fun onDestroy() {
- super.onDestroy()
- stopV2Ray()
- }
-
- private fun setup(parameters: String) {
-
- val prepare = prepare(this)
- if (prepare != null) {
- return
- }
-
- // If the old interface has exactly the same parameters, use it!
- // Configure a builder while parsing the parameters.
- val builder = Builder()
- val enableLocalDns = settingsStorage?.decodeBool(AppConfig.PREF_LOCAL_DNS_ENABLED) ?: false
- val routingMode = settingsStorage?.decodeString(AppConfig.PREF_ROUTING_MODE) ?: "0"
-
- parameters.split(" ")
- .map { it.split(",") }
- .forEach {
- when (it[0][0]) {
- 'm' -> builder.setMtu(java.lang.Short.parseShort(it[1]).toInt())
- 's' -> builder.addSearchDomain(it[1])
- 'a' -> builder.addAddress(it[1], Integer.parseInt(it[2]))
- 'r' -> {
- if (routingMode == "1" || routingMode == "3") {
- if (it[1] == "::") { //not very elegant, should move Vpn setting in Kotlin, simplify go code
- builder.addRoute("2000::", 3)
- } else {
- resources.getStringArray(R.array.bypass_private_ip_address).forEach { cidr ->
- val addr = cidr.split('/')
- builder.addRoute(addr[0], addr[1].toInt())
- }
- }
- } else {
- builder.addRoute(it[1], Integer.parseInt(it[2]))
- }
- }
- 'd' -> builder.addDnsServer(it[1])
- }
- }
-
- if(!enableLocalDns) {
- Utils.getVpnDnsServers()
- .forEach {
- if (Utils.isPureIpAddress(it)) {
- builder.addDnsServer(it)
- }
- }
- }
-
- builder.setSession(V2RayServiceManager.currentConfig?.remarks.orEmpty())
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
- settingsStorage?.decodeBool(AppConfig.PREF_PER_APP_PROXY) == true) {
- val apps = settingsStorage?.decodeStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
- val bypassApps = settingsStorage?.decodeBool(AppConfig.PREF_BYPASS_APPS) ?: false
- apps?.forEach {
- try {
- if (bypassApps)
- builder.addDisallowedApplication(it)
- else
- builder.addAllowedApplication(it)
- } catch (e: PackageManager.NameNotFoundException) {
- //Logger.d(e)
- }
- }
- }
-
- // Close the old interface since the parameters have been changed.
- try {
- mInterface.close()
- } catch (ignored: Exception) {
- // ignored
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- try {
- connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- builder.setMetered(false)
- }
-
- // Create a new interface using the builder and save the parameters.
- try {
- mInterface = builder.establish()!!
- } catch (e: Exception) {
- // non-nullable lateinit var
- e.printStackTrace()
- stopV2Ray()
- }
-
- sendFd()
- }
-
- private fun sendFd() {
- val fd = mInterface.fileDescriptor
- val path = File(Utils.packagePath(applicationContext), "sock_path").absolutePath
-
- GlobalScope.launch(Dispatchers.IO) {
- var tries = 0
- while (true) try {
- Thread.sleep(1000L shl tries)
- Log.d(packageName, "sendFd tries: $tries")
- LocalSocket().use { localSocket ->
- localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM))
- localSocket.setFileDescriptorsForSend(arrayOf(fd))
- localSocket.outputStream.write(42)
- }
- break
- } catch (e: Exception) {
- Log.d(packageName, e.toString())
- if (tries > 5) break
- tries += 1
- }
- }
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- V2RayServiceManager.startV2rayPoint()
- return START_STICKY
- //return super.onStartCommand(intent, flags, startId)
- }
-
- private fun stopV2Ray(isForced: Boolean = true) {
-// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
-// val emptyInfo = VpnNetworkInfo()
-// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
-// saveVpnNetworkInfo(configName, info)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- try {
- connectivity.unregisterNetworkCallback(defaultNetworkCallback)
- } catch (ignored: Exception) {
- // ignored
- }
- }
-
- V2RayServiceManager.stopV2rayPoint()
-
- if (isForced) {
- //stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
- //It's strage but true.
- //This can be verified by putting stopself() behind and call stopLoop and startLoop
- //in a row for several times. You will find that later created v2ray core report port in use
- //which means the first v2ray core somehow failed to stop and release the port.
- stopSelf()
-
- try {
- mInterface.close()
- } catch (ignored: Exception) {
- // ignored
- }
-
- }
- }
-
- override fun getService(): Service {
- return this
- }
-
- override fun startService(parameters: String) {
- setup(parameters)
- }
-
- override fun stopService() {
- stopV2Ray(true)
- }
-
- override fun vpnProtect(socket: Int): Boolean {
- return protect(socket)
- }
-
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt
deleted file mode 100644
index 3f11b649..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/BaseActivity.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.v2ray.ang.ui
-
-import androidx.appcompat.app.AppCompatActivity
-import android.view.MenuItem
-
-abstract class BaseActivity : AppCompatActivity() {
- override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
- android.R.id.home -> {
- onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
-}
diff --git a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt b/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt
deleted file mode 100644
index 146884fa..00000000
--- a/V2rayNG/app/src/main/kotlin/com/v2ray/ang/ui/LogcatActivity.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.v2ray.ang.ui
-
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.text.method.ScrollingMovementMethod
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import com.v2ray.ang.AppConfig.ANG_PACKAGE
-import com.v2ray.ang.R
-import com.v2ray.ang.databinding.ActivityLogcatBinding
-import com.v2ray.ang.extension.toast
-import com.v2ray.ang.util.Utils
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import java.io.IOException
-import java.util.LinkedHashSet
-
-class LogcatActivity : BaseActivity() {
- private lateinit var binding: ActivityLogcatBinding
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- binding = ActivityLogcatBinding.inflate(layoutInflater)
- val view = binding.root
- setContentView(view)
-
- title = getString(R.string.title_logcat)
-
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- logcat(false)
- }
-
- private fun logcat(shouldFlushLog: Boolean) {
-
- try {
- binding.pbWaiting.visibility = View.VISIBLE
-
- GlobalScope.launch(Dispatchers.Default) {
- if (shouldFlushLog) {
- val lst = LinkedHashSet()
- lst.add("logcat")
- lst.add("-c")
- val process = Runtime.getRuntime().exec(lst.toTypedArray())
- process.waitFor()
- }
- val lst = LinkedHashSet