mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-30 23:16:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83abd61a7b | ||
|
|
4dc3a20a5c | ||
|
|
bcfb9c2bc9 | ||
|
|
55103078c2 | ||
|
|
8b81fdf383 | ||
|
|
17b6254d4d | ||
|
|
435182e0c9 | ||
|
|
257c4bf21b | ||
|
|
389e55d72a | ||
|
|
490a07d75b | ||
|
|
d4ebab69c7 | ||
|
|
97195f2d3a | ||
|
|
be2dabe029 | ||
|
|
3fc03bad2b | ||
|
|
888d7cd398 | ||
|
|
919dd71ea7 | ||
|
|
2c1efde450 | ||
|
|
e549000e5a | ||
|
|
aacc8e091f | ||
|
|
070abd78ea | ||
|
|
3380a45475 | ||
|
|
4fee3a67f7 | ||
|
|
794940a1a0 | ||
|
|
8421d2300b | ||
|
|
596084b939 | ||
|
|
35c39e4f36 | ||
|
|
4295d5d6d1 | ||
|
|
827682b768 | ||
|
|
9896dd2974 | ||
|
|
842567fb89 | ||
|
|
cce2c9cdbd | ||
|
|
7f6bf57ce1 | ||
|
|
5ee70b723b | ||
|
|
2be47f37fe | ||
|
|
ce75aad55e | ||
|
|
c9e1501d39 | ||
|
|
3be230c024 | ||
|
|
3ae13b0ba4 | ||
|
|
a231151e70 | ||
|
|
f7f170e8d3 | ||
|
|
1b13fadce7 | ||
|
|
fc39e045fc | ||
|
|
60562acb80 | ||
|
|
4949528eeb | ||
|
|
f6ea2df5a0 | ||
|
|
4b903cf5e0 | ||
|
|
ccadd5453a | ||
|
|
7213f9fa9f | ||
|
|
6a00dff160 | ||
|
|
4c27f8682a | ||
|
|
607311efd2 | ||
|
|
f1eac6fb8c | ||
|
|
1b9401fc42 | ||
|
|
1dee6230f9 | ||
|
|
ca79a574fa | ||
|
|
eeec6de96c | ||
|
|
b02647d664 | ||
|
|
90ae44e532 | ||
|
|
aa55cdca76 | ||
|
|
04a5c56844 | ||
|
|
aaf071053b | ||
|
|
70352cc341 | ||
|
|
1e3f16b083 | ||
|
|
7cbb0febf0 | ||
|
|
ac03b0e87b | ||
|
|
ca9b60fceb | ||
|
|
09caeec326 | ||
|
|
346c6cd60c | ||
|
|
c74a6cade3 | ||
|
|
f6f981f9fd | ||
|
|
16cd06f07a | ||
|
|
1eaea8e161 | ||
|
|
69cf1e70c1 | ||
|
|
c49d7e98b1 | ||
|
|
7ccac9eb2d | ||
|
|
1338ec8e64 | ||
|
|
b95db14005 | ||
|
|
8d726fa829 | ||
|
|
06e512f5c5 | ||
|
|
9397a9e4a1 | ||
|
|
223eb1494b |
No files matched your search
@@ -81,7 +81,7 @@ jobs:
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
@@ -122,6 +122,13 @@ jobs:
|
||||
name: x86-apk
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*x86*.apk
|
||||
|
||||
- name: Upload universal APK
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: universal-apk
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*universal*.apk
|
||||
|
||||
sign-and-release:
|
||||
needs: build
|
||||
runs-on: ubuntu-26.04
|
||||
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: 3b5a9c858c...b21389865e.
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("com.jaredsburrows.license")
|
||||
}
|
||||
|
||||
@@ -12,9 +13,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 736
|
||||
versionName = "2.2.6"
|
||||
multiDexEnabled = true
|
||||
versionCode = 741
|
||||
versionName = "2.3.1"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
@@ -124,8 +124,8 @@ android {
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -142,20 +142,19 @@ dependencies {
|
||||
|
||||
// AndroidX Core Libraries
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.preference.ktx)
|
||||
implementation(libs.recyclerview)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
implementation(libs.androidx.fragment)
|
||||
|
||||
// UI Libraries
|
||||
implementation(libs.material)
|
||||
implementation(libs.toasty)
|
||||
implementation(libs.editorkit)
|
||||
implementation(libs.flexbox)
|
||||
// Compose Libraries
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Data and Storage Libraries
|
||||
implementation(libs.mmkv.static)
|
||||
@@ -166,25 +165,23 @@ dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
// Language and Processing Libraries
|
||||
implementation(libs.language.base)
|
||||
implementation(libs.language.json)
|
||||
|
||||
// Intent and Utility Libraries
|
||||
implementation(libs.quickie.foss)
|
||||
implementation(libs.core)
|
||||
// QR Code: CameraX + ZXing
|
||||
implementation(libs.camerax.core)
|
||||
implementation(libs.camerax.camera2)
|
||||
implementation(libs.camerax.lifecycle)
|
||||
implementation(libs.camerax.compose)
|
||||
implementation(libs.core) // zxing core
|
||||
|
||||
// AndroidX Lifecycle and Architecture Components
|
||||
implementation(libs.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.lifecycle.livedata.ktx)
|
||||
implementation(libs.lifecycle.runtime.ktx)
|
||||
|
||||
// Background Task Libraries
|
||||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.work.multiprocess)
|
||||
|
||||
// Multidex Support
|
||||
implementation(libs.multidex)
|
||||
// Reorderable list
|
||||
implementation(libs.reorderable)
|
||||
|
||||
// Testing Libraries
|
||||
testImplementation(libs.junit)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScSwitchActivity"
|
||||
android:targetClass="com.v2ray.ang.ui.shortcut.ScSwitchActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
@@ -26,7 +26,7 @@
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScScannerActivity"
|
||||
android:targetClass="com.v2ray.ang.ui.shortcut.ScScannerActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
@@ -41,7 +41,7 @@
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScStartActivity"
|
||||
android:targetClass="com.v2ray.ang.ui.shortcut.ScStartActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
@@ -56,7 +56,7 @@
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScStopActivity"
|
||||
android:targetClass="com.v2ray.ang.ui.shortcut.ScStopActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
@@ -71,72 +71,102 @@
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.ServerActivity"
|
||||
android:name=".ui.server.ServerVmessActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.ServerCustomConfigActivity"
|
||||
android:name=".ui.server.ServerVlessActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.ServerGroupActivity"
|
||||
android:name=".ui.server.ServerShadowsocksActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.ServerProxyChainActivity"
|
||||
android:name=".ui.server.ServerSocksActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:name=".ui.server.ServerHttpActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerTrojanActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerWireguardActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerHysteria2Activity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerCustomConfigActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerGroupActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerProxyChainActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.settings.SettingsActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.PerAppProxyActivity"
|
||||
android:name=".ui.perappproxy.PerAppProxyActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.AppPickerActivity"
|
||||
android:name=".ui.apppicker.AppPickerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ScannerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.LogcatActivity"
|
||||
android:name=".ui.logcat.LogcatActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.RoutingSettingActivity"
|
||||
android:name=".ui.routing.RoutingSettingActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.RoutingEditActivity"
|
||||
android:name=".ui.routing.RoutingEditActivity"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.SubSettingActivity"
|
||||
android:name=".ui.subscription.SubSettingActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.UserAssetActivity"
|
||||
android:name=".ui.userasset.UserAssetActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.UserAssetUrlActivity"
|
||||
android:name=".ui.userasset.UserAssetUrlActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.SubEditActivity"
|
||||
android:name=".ui.subscription.SubEditActivity"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ScScannerActivity"
|
||||
android:name=".ui.shortcut.ScScannerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ScSwitchActivity"
|
||||
android:name=".ui.shortcut.ScSwitchActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.ScStartActivity"
|
||||
android:name=".ui.shortcut.ScStartActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.ScStopActivity"
|
||||
android:name=".ui.shortcut.ScStopActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
@@ -162,10 +192,10 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.CheckUpdateActivity"
|
||||
android:name=".ui.checkupdate.CheckUpdateActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.BackupActivity"
|
||||
android:name=".ui.backup.BackupActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
@@ -223,6 +253,16 @@
|
||||
android:value="test" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.SubscriptionUpdateService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.WidgetProvider"
|
||||
android:exported="true"
|
||||
@@ -242,6 +282,8 @@
|
||||
android:label="BootReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
@@ -262,7 +304,7 @@
|
||||
</service>
|
||||
<!-- =====================Tasker===================== -->
|
||||
<activity
|
||||
android:name=".ui.TaskerActivity"
|
||||
android:name=".ui.shortcut.TaskerActivity"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<intent-filter>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package com.v2ray.ang
|
||||
|
||||
import android.app.Application
|
||||
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
|
||||
import com.v2ray.ang.ui.compose.ThemeManager
|
||||
|
||||
class AngApplication : MultiDexApplication() {
|
||||
class AngApplication : Application() {
|
||||
companion object {
|
||||
lateinit var application: AngApplication
|
||||
}
|
||||
@@ -39,10 +40,8 @@ class AngApplication : MultiDexApplication() {
|
||||
|
||||
// Ensure critical preference defaults are present in MMKV early
|
||||
SettingsManager.initApp(this)
|
||||
SettingsManager.setNightMode()
|
||||
|
||||
es.dmoral.toasty.Toasty.Config.getInstance()
|
||||
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
|
||||
.apply()
|
||||
// Initialize theme state from MMKV
|
||||
ThemeManager.refresh()
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,15 @@ object AppConfig {
|
||||
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
|
||||
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
|
||||
const val PREF_FRAGMENT_MAXSPLIT = "pref_fragment_maxsplit"
|
||||
const val PREF_OBSERVATORY_LEAST_PING_INTERVAL = "pref_observatory_least_ping_interval"
|
||||
const val PREF_OBSERVATORY_LEAST_LOAD_INTERVAL = "pref_observatory_least_load_interval"
|
||||
const val PREF_OBSERVATORY_LEAST_LOAD_METHOD = "pref_observatory_least_load_method"
|
||||
const val PREF_OBSERVATORY_LEAST_LOAD_SAMPLING = "pref_observatory_least_load_sampling"
|
||||
const val PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT = "pref_observatory_least_load_timeout"
|
||||
const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
|
||||
const val SUBSCRIPTION_MIN_INTERVAL_MINUTES = 15L
|
||||
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||
const val PREF_LANGUAGE = "pref_language"
|
||||
@@ -78,6 +82,8 @@ object AppConfig {
|
||||
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
|
||||
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
|
||||
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
|
||||
const val PREF_UPDATE_SUBSCRIPTION = "pref_update_subscription"
|
||||
const val PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION = "pref_auto_test_after_update_subscription"
|
||||
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
|
||||
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
|
||||
@@ -128,12 +134,17 @@ object AppConfig {
|
||||
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
|
||||
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
|
||||
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
|
||||
const val OBSERVATORY_LEAST_PING_INTERVAL = "3m"
|
||||
const val OBSERVATORY_LEAST_LOAD_INTERVAL = "5m"
|
||||
const val OBSERVATORY_LEAST_LOAD_METHOD = "HEAD"
|
||||
const val OBSERVATORY_LEAST_LOAD_SAMPLING = "2"
|
||||
const val OBSERVATORY_LEAST_LOAD_TIMEOUT = "30s"
|
||||
|
||||
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||
const val IP_API_URL = "https://api.ip.sb/geoip"
|
||||
|
||||
/** DNS server addresses. */
|
||||
const val DNS_PROXY = "1.1.1.1"
|
||||
const val DNS_PROXY = "https://cloudflare-dns.com/dns-query"
|
||||
const val DNS_DIRECT = "223.5.5.5"
|
||||
const val DNS_VPN = "1.1.1.1"
|
||||
const val GEOSITE_PRIVATE = "geosite:private"
|
||||
@@ -174,9 +185,13 @@ object AppConfig {
|
||||
const val MSG_MEASURE_CONFIG_NOTIFY = 73
|
||||
const val MSG_MEASURE_CONFIG_FINISH = 74
|
||||
|
||||
const val MSG_SUB_UPDATE_START = 8
|
||||
const val MSG_SUB_UPDATE_CANCEL = 81
|
||||
|
||||
/** 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"
|
||||
// Use a new ID because Android does not let an app raise an existing channel's importance.
|
||||
const val RAY_NG_CHANNEL_ID = "CORE_M_CH_ID_V2"
|
||||
const val RAY_NG_CHANNEL_NAME = "Core Background Service"
|
||||
|
||||
/** Protocols Scheme **/
|
||||
const val VMESS = "vmess://"
|
||||
@@ -193,30 +208,31 @@ object AppConfig {
|
||||
const val HYSTERIA = "hysteria://"
|
||||
const val HYSTERIA2 = "hysteria2://"
|
||||
const val HY2 = "hy2://"
|
||||
const val V2RAYNFMTS = "v2rayn://"
|
||||
|
||||
/** Give a good name to this, IDK*/
|
||||
const val VPN = "VPN"
|
||||
const val VPN_MTU = 1500
|
||||
|
||||
/** Root (system-wide) mode runtime constants. */
|
||||
const val ROOT_RUNTIME_DIR = "root"
|
||||
const val ROOT_IPTABLES_CHAIN = "V2RAY_NG"
|
||||
const val ROOT_RUNTIME_DIR = "sys_cache"
|
||||
const val ROOT_IPTABLES_CHAIN = "CORE_FILTER"
|
||||
const val ROOT_FWMARK = 255 // defensive RETURN tag; hev's only upstream socket is loopback (already bypassed)
|
||||
const val ROOT_MARK_ROUTE = 1 // packets we want pushed into the tun device
|
||||
const val ROOT_ROUTE_TABLE = 2024
|
||||
const val ROOT_RULE_PRIORITY = 1000
|
||||
const val ROOT_TUN_NAME = "v2raytun0"
|
||||
const val ROOT_TUN_NAME = "utun7788"
|
||||
const val ROOT_TUN_ADDR_V4 = "198.18.0.1/15"
|
||||
const val ROOT_TUN_ADDR_V6 = "fdfe:dcba:9876::1/64"
|
||||
|
||||
// hev-socks5-tunnel run as a standalone root binary (reuses the same project already
|
||||
// bundled for the VPN hev path; distinct filename from the JNI lib to avoid collision).
|
||||
const val ROOT_TUN2SOCKS_BIN = "libhevsockstun.so"
|
||||
const val ROOT_FWD_CHAIN = "V2RAY_NG_FWD" // FORWARD chain for LAN/tethering sharing
|
||||
const val ROOT_DNS_CHAIN = "V2RAY_NG_DNS" // nat chain for tethered-client DNS DNAT
|
||||
const val ROOT_V6_CHAIN = "V2RAY_NG6" // ip6tables filter/OUTPUT chain: blackhole native IPv6 when it isn't tunneled
|
||||
const val ROOT_V6_FWD_CHAIN = "V2RAY_NG6_FWD" // ip6tables FORWARD chain: route or reject tethered clients' native IPv6
|
||||
const val ROOT_V6_PRE_CHAIN = "V2RAY_NG6_PRE" // ip6tables mangle/PREROUTING chain: mark forwarded clients' IPv6 into the tun
|
||||
const val ROOT_FWD_CHAIN = "CORE_FWD" // FORWARD chain for LAN/tethering sharing
|
||||
const val ROOT_DNS_CHAIN = "CORE_DNS" // nat chain for tethered-client DNS DNAT
|
||||
const val ROOT_V6_CHAIN = "CORE6_FILTER" // ip6tables filter/OUTPUT chain: blackhole native IPv6 when it isn't tunneled
|
||||
const val ROOT_V6_FWD_CHAIN = "CORE6_FWD" // ip6tables FORWARD chain: route or reject tethered clients' native IPv6
|
||||
const val ROOT_V6_PRE_CHAIN = "CORE6_PRE" // ip6tables mangle/PREROUTING chain: mark forwarded clients' IPv6 into the tun
|
||||
const val ROOT_LAN_DNS = "1.1.1.1" // fallback resolver for tethered clients when no plain-IPv4 DNS is configured
|
||||
const val ROOT_OOM_SCORE = "-1000" // oom_score_adj that makes the LMK never kill us
|
||||
|
||||
@@ -324,4 +340,6 @@ object AppConfig {
|
||||
TAG_DIRECT,
|
||||
TAG_BLOCKED,
|
||||
)
|
||||
|
||||
val OBSERVATORY_DURATION_PATTERN = Regex("""[1-9]\d*(ms|s|m|h)""")
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
/**
|
||||
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
|
||||
* Extend this interface or define more specific interfaces for different adapters as needed.
|
||||
*/
|
||||
interface BaseAdapterListener {
|
||||
/**
|
||||
* Request the host to edit the specified item.
|
||||
* @param guid Unique identifier (GUID) of the item
|
||||
* @param position Current position in the adapter (optional; host should validate it)
|
||||
*/
|
||||
fun onEdit(guid: String, position: Int)
|
||||
|
||||
/**
|
||||
* Request the host to remove the specified item. Position is provided for optional animation or validation.
|
||||
* @param guid Unique identifier (GUID) of the item
|
||||
* @param position Current position in the adapter (optional; host should validate it)
|
||||
*/
|
||||
fun onRemove(guid: String, position: Int)
|
||||
|
||||
/**
|
||||
* Request the host to share the specified URL.
|
||||
* @param url The URL to be shared
|
||||
*/
|
||||
fun onShare(url: String)
|
||||
|
||||
/**
|
||||
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
|
||||
*/
|
||||
fun onRefreshData()
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.service
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import android.content.Context
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
interface MainAdapterListener : BaseAdapterListener {
|
||||
|
||||
fun onEdit(guid: String, position: Int, profile: ProfileItem)
|
||||
|
||||
fun onSelectServer(guid: String)
|
||||
|
||||
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import android.app.Service
|
||||
import android.net.Network
|
||||
|
||||
interface ServiceControl {
|
||||
/**
|
||||
@@ -25,4 +26,13 @@ interface ServiceControl {
|
||||
* @return True if the socket is protected, false otherwise.
|
||||
*/
|
||||
fun vpnProtect(socket: Int): Boolean
|
||||
|
||||
/**
|
||||
* Declares the networks the tunnel runs on top of.
|
||||
* Only meaningful for the VPN service, the other run modes have no interface to report.
|
||||
*
|
||||
* @param networks The upstream networks, null to let the system pick.
|
||||
* @return True if the networks were accepted.
|
||||
*/
|
||||
fun setUnderlyingNetworks(networks: Array<Network>?): Boolean = false
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.CoreConfigContext
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.BalancerStrategyType
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
@@ -43,12 +44,14 @@ object CoreConfigContextBuilder {
|
||||
|
||||
// Step 2: Resolve all non-builtin routing outbound tags.
|
||||
val routingResolvedOutbounds = resolveRoutingOutbounds()
|
||||
val resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds
|
||||
val fallbackResolvedOutbounds = resolveFallbackOutbounds(resolvedOutbounds)
|
||||
val routingDomainRules = collectRoutingDomainRulesForDns()
|
||||
|
||||
return CoreConfigContext(
|
||||
context = context,
|
||||
guid = guid,
|
||||
resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds,
|
||||
resolvedOutbounds = resolvedOutbounds + fallbackResolvedOutbounds,
|
||||
routingDomainRules = routingDomainRules,
|
||||
)
|
||||
}
|
||||
@@ -246,4 +249,25 @@ object CoreConfigContextBuilder {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and collect fallback outbounds from all POLICYGROUP nodes.
|
||||
*
|
||||
* Fallback targets must not overlap with already resolved tags or builtin tags.
|
||||
*/
|
||||
private fun resolveFallbackOutbounds(resolvedOutbounds: List<CoreConfigContext.ResolvedOutbound>): List<CoreConfigContext.ResolvedOutbound> {
|
||||
return resolvedOutbounds
|
||||
.asSequence()
|
||||
.filter { it.resolvedType == CoreResolvedType.POLICYGROUP }
|
||||
.filter { BalancerStrategyType.from(it.profile.policyGroupType).supportsObservatory && it.profile.policyGroupTestOutbounds != false }
|
||||
.mapNotNull { it.profile.policyGroupFallbackTag }
|
||||
.filter { it !in AppConfig.BUILTIN_OUTBOUND_TAGS && resolvedOutbounds.none { outbound -> outbound.tag == it } }
|
||||
.distinct()
|
||||
.mapNotNull { tag ->
|
||||
SettingsManager.getServerViaRemarks(tag)
|
||||
?.takeUnless { it.configType == EConfigType.CUSTOM || it.configType == EConfigType.POLICYGROUP }
|
||||
?.let { resolveOutbound(tag, it) }
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
@@ -374,10 +374,19 @@ object CoreConfigManager {
|
||||
} else {
|
||||
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
|
||||
}
|
||||
val strategyType = BalancerStrategyType.from(resolvedOutbound.profile.policyGroupType)
|
||||
val fallbackTag = if (strategyType.supportsObservatory && resolvedOutbound.profile.policyGroupTestOutbounds != false) {
|
||||
resolvedOutbound.profile.policyGroupFallbackTag
|
||||
?.takeIf { it.isNotEmpty() && it != AppConfig.TAG_PROXY }
|
||||
// Xray excludes dead random/roundRobin candidates only when fallbackTag is set;
|
||||
// without this default, an enabled empty field creates no observatory.
|
||||
?: membersToAdd.first().tag
|
||||
} else null
|
||||
val strategy = buildBalancerStrategy(
|
||||
policyGroupType = resolvedOutbound.profile.policyGroupType,
|
||||
strategyType = strategyType,
|
||||
selector = listOf(memberTagPrefix),
|
||||
balancerTag = balancerTag,
|
||||
fallbackTag = fallbackTag,
|
||||
)
|
||||
val existingBalancers = v2rayConfig.routing.balancers?.toMutableList() ?: mutableListOf()
|
||||
if (existingBalancers.none { it.tag == balancerTag }) {
|
||||
@@ -915,7 +924,7 @@ object CoreConfigManager {
|
||||
return hosts
|
||||
}
|
||||
|
||||
private fun buildDnsCnModeFromRoutingRules(configContext: CoreConfigContext, servers: ArrayList<Any>, domesticDns: List<String>, ): List<String> {
|
||||
private fun buildDnsCnModeFromRoutingRules(configContext: CoreConfigContext, servers: ArrayList<Any>, domesticDns: List<String>): List<String> {
|
||||
val cnRegionFilter = { domain: String ->
|
||||
domain.startsWith("geosite:") && (domain.endsWith("-cn") || domain.endsWith("@cn"))
|
||||
|| domain == AppConfig.GEOSITE_CN
|
||||
@@ -981,6 +990,7 @@ object CoreConfigManager {
|
||||
domesticDnsTags.add(tag)
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.TAG_BLOCKED -> Unit
|
||||
else -> {
|
||||
servers.add(
|
||||
@@ -1179,22 +1189,28 @@ object CoreConfigManager {
|
||||
* Build balancer and probe settings from one policy-group strategy value.
|
||||
*/
|
||||
private fun buildBalancerStrategy(
|
||||
policyGroupType: String?,
|
||||
strategyType: BalancerStrategyType,
|
||||
selector: List<String>,
|
||||
balancerTag: String = AppConfig.TAG_BALANCER,
|
||||
fallbackTag: String? = null,
|
||||
): BalancerStrategy {
|
||||
val probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL
|
||||
val strategyType = BalancerStrategyType.from(policyGroupType)
|
||||
val leastPingInterval = decodeObservatoryDuration(AppConfig.PREF_OBSERVATORY_LEAST_PING_INTERVAL, AppConfig.OBSERVATORY_LEAST_PING_INTERVAL)
|
||||
val leastLoadInterval = decodeObservatoryDuration(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_INTERVAL, AppConfig.OBSERVATORY_LEAST_LOAD_INTERVAL)
|
||||
val leastLoadMethod = MmkvManager.decodeSettingsString(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_METHOD, AppConfig.OBSERVATORY_LEAST_LOAD_METHOD)
|
||||
val leastLoadSampling = decodeObservatorySampling()
|
||||
val leastLoadTimeout = decodeObservatoryDuration(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT, AppConfig.OBSERVATORY_LEAST_LOAD_TIMEOUT)
|
||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||
tag = balancerTag,
|
||||
selector = selector,
|
||||
fallbackTag = fallbackTag,
|
||||
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
|
||||
)
|
||||
val observatory = if (strategyType.requiresObservatory) {
|
||||
val observatory = if (strategyType.requiresObservatory || fallbackTag != null) {
|
||||
V2rayConfig.ObservatoryObject(
|
||||
subjectSelector = selector,
|
||||
probeUrl = probeUrl,
|
||||
probeInterval = "3m",
|
||||
probeInterval = leastPingInterval,
|
||||
enableConcurrency = true
|
||||
)
|
||||
} else null
|
||||
@@ -1203,15 +1219,33 @@ object CoreConfigManager {
|
||||
subjectSelector = selector,
|
||||
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
||||
destination = probeUrl,
|
||||
interval = "5m",
|
||||
sampling = 2,
|
||||
timeout = "30s"
|
||||
httpMethod = leastLoadMethod,
|
||||
interval = leastLoadInterval,
|
||||
sampling = leastLoadSampling,
|
||||
timeout = leastLoadTimeout
|
||||
)
|
||||
)
|
||||
} else null
|
||||
return BalancerStrategy(balancer, observatory, burstObservatory)
|
||||
}
|
||||
|
||||
private fun decodeObservatoryDuration(key: String, default: String): String {
|
||||
val value = MmkvManager.decodeSettingsString(key)?.trim()
|
||||
return if (!value.isNullOrEmpty() && AppConfig.OBSERVATORY_DURATION_PATTERN.matches(value)) {
|
||||
value
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeObservatorySampling(): Int {
|
||||
return MmkvManager.decodeSettingsString(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_SAMPLING)
|
||||
?.trim()
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?: AppConfig.OBSERVATORY_LEAST_LOAD_SAMPLING.toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry balancer data plus optional probe settings for later merge.
|
||||
*/
|
||||
@@ -1222,4 +1256,4 @@ object CoreConfigManager {
|
||||
)
|
||||
|
||||
//endregion
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ object CoreOutboundBuilder {
|
||||
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) {
|
||||
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
|
||||
outbound.mux?.concurrency = -1
|
||||
}
|
||||
} else {
|
||||
@@ -83,49 +83,30 @@ object CoreOutboundBuilder {
|
||||
fun createInitOutbound(configType: EConfigType): OutboundBean? {
|
||||
return when (configType) {
|
||||
EConfigType.VMESS,
|
||||
EConfigType.VLESS ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
vnext = listOf(
|
||||
OutboundBean.OutSettingsBean.VnextBean(
|
||||
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
|
||||
)
|
||||
)
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.VLESS,
|
||||
EConfigType.SHADOWSOCKS,
|
||||
EConfigType.SOCKS,
|
||||
EConfigType.HTTP,
|
||||
EConfigType.TROJAN ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
EConfigType.TROJAN -> OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.WIREGUARD ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
secretKey = "",
|
||||
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
|
||||
)
|
||||
EConfigType.WIREGUARD -> OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
secretKey = "",
|
||||
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
|
||||
)
|
||||
)
|
||||
|
||||
EConfigType.HYSTERIA,
|
||||
EConfigType.HYSTERIA2 ->
|
||||
return OutboundBean(
|
||||
protocol = EConfigType.HYSTERIA.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
servers = null
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
EConfigType.HYSTERIA2 -> OutboundBean(
|
||||
protocol = EConfigType.HYSTERIA.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
@@ -136,11 +117,12 @@ object CoreOutboundBuilder {
|
||||
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.VMESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].security = profileItem.method
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.id = profileItem.password.orEmpty()
|
||||
settings.security = profileItem.method
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
@@ -157,12 +139,13 @@ object CoreOutboundBuilder {
|
||||
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.VLESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].encryption = profileItem.method
|
||||
vnext.users[0].flow = profileItem.flow
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.id = profileItem.password.orEmpty()
|
||||
settings.encryption = profileItem.method
|
||||
settings.flow = profileItem.flow
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
@@ -179,11 +162,12 @@ object CoreOutboundBuilder {
|
||||
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.method = profileItem.method
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.password = profileItem.password
|
||||
settings.method = profileItem.method
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
@@ -200,11 +184,12 @@ object CoreOutboundBuilder {
|
||||
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.TROJAN)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.flow = profileItem.flow
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.password = profileItem.password
|
||||
settings.flow = profileItem.flow
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
@@ -221,14 +206,13 @@ object CoreOutboundBuilder {
|
||||
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.SOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
settings.user = profileItem.username.orEmpty()
|
||||
settings.pass = profileItem.password.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,14 +222,13 @@ object CoreOutboundBuilder {
|
||||
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.HTTP)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
settings.user = profileItem.username.orEmpty()
|
||||
settings.pass = profileItem.password.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +264,13 @@ object CoreOutboundBuilder {
|
||||
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||
}
|
||||
|
||||
if (!profileItem.finalMask.isNullOrBlank()) {
|
||||
outboundBean?.streamSettings = OutboundBean.StreamSettingsBean()
|
||||
outboundBean?.streamSettings?.let {
|
||||
updateOutboundFinalMask(it, profileItem)
|
||||
it.network = null
|
||||
}
|
||||
}
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
@@ -687,4 +677,16 @@ object CoreOutboundBuilder {
|
||||
}
|
||||
return resolvedIps.first()
|
||||
}
|
||||
|
||||
fun updateOutboundFinalMask(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem) {
|
||||
val finalMask = profileItem.finalMask
|
||||
finalMask?.let {
|
||||
val parsedFinalMask = JsonUtil.parseString(profileItem.finalMask)
|
||||
if (parsedFinalMask != null) {
|
||||
streamSettings.finalmask = parsedFinalMask
|
||||
} else {
|
||||
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,29 +12,26 @@ import android.system.OsConstants
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.IDialerService
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.dto.OutboundTrafficStat
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.enums.BrowserDialerMode
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SpeedtestManager
|
||||
import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||
import com.v2ray.ang.service.CoreRootService
|
||||
import com.v2ray.ang.service.CoreVpnService
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.service.DialerNativeService
|
||||
import com.v2ray.ang.service.DialerWebviewService
|
||||
import com.v2ray.ang.service.IDialerService
|
||||
import com.v2ray.ang.service.NetworkMonitor
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.jvm.Volatile
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
import libv2ray.ProcessFinder
|
||||
@@ -48,6 +45,13 @@ object CoreServiceManager {
|
||||
private var currentConfig: ProfileItem? = null
|
||||
private var processFinder: XrayProcessFinder? = null
|
||||
private var browserDialer: IDialerService? = null
|
||||
private var networkMonitor: NetworkMonitor? = null
|
||||
|
||||
@Volatile
|
||||
private var isReloading = false
|
||||
|
||||
/** Tun descriptor the core was started with, null in the proxy only and root run modes. */
|
||||
private var currentVpnInterface: ParcelFileDescriptor? = null
|
||||
|
||||
var serviceControl: SoftReference<ServiceControl>? = null
|
||||
set(value) {
|
||||
@@ -60,55 +64,6 @@ object CoreServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||
|
||||
if (guid != null) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -121,98 +76,13 @@ object CoreServiceManager {
|
||||
*/
|
||||
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.
|
||||
* @throws IllegalStateException if the core is already running, no server is selected,
|
||||
* server config cannot be decoded, or server configuration is invalid.
|
||||
* @throws Exception if the foreground service fails to start.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
private fun startContextService(context: Context) {
|
||||
if (coreController.isRunning) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||
error(context.getString(R.string.app_tile_first_use))
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
if (!config.configType.isComplexType()
|
||||
&& !Utils.isValidUrl(config.server)
|
||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
// refresh socks port when enabled dynamic socks port
|
||||
SettingsManager.refreshRuntimeSocksPort()
|
||||
|
||||
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||
// if (!result.status) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||
|
||||
if (config.insecure == true) {
|
||||
context.toastError(R.string.toast_allow_insecure_deprecated)
|
||||
context.toastError(R.string.toast_allow_insecure_deprecated)
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||
} else {
|
||||
context.toast(R.string.toast_services_start)
|
||||
}
|
||||
|
||||
val isRootMode = SettingsManager.isRootMode()
|
||||
if (isRootMode && !RootManager.isRootAvailable()) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: root mode requires root but none available")
|
||||
error(context.getString(R.string.toast_root_required))
|
||||
}
|
||||
|
||||
val intent = if (isRootMode) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Root service")
|
||||
Intent(context.applicationContext, CoreRootService::class.java)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||
Intent(context.applicationContext, CoreVpnService::class.java)
|
||||
} else {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
|
||||
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
|
||||
}
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: SecurityException) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Missing permission to start foreground service", e)
|
||||
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||
} catch (e: RuntimeException) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Foreground service start not allowed", e)
|
||||
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(vpnInterface: ParcelFileDescriptor?): Boolean {
|
||||
if (coreController.isRunning) {
|
||||
if (isRunning()) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return false
|
||||
}
|
||||
@@ -229,7 +99,7 @@ object CoreServiceManager {
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: $message", e)
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
|
||||
NotificationManager.cancelNotification()
|
||||
return false
|
||||
}
|
||||
@@ -237,6 +107,19 @@ object CoreServiceManager {
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
|
||||
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())
|
||||
|
||||
currentVpnInterface = vpnInterface
|
||||
launchCore(service, vpnInterface)
|
||||
startNetworkMonitor(service)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun launchCore(service: Service, vpnInterface: ParcelFileDescriptor?, isReload: Boolean = false) {
|
||||
val guid = MmkvManager.getSelectServer() ?: error("No server selected")
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
|
||||
|
||||
@@ -247,28 +130,25 @@ object CoreServiceManager {
|
||||
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||
}
|
||||
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_ON)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
|
||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
|
||||
currentConfig = config
|
||||
var tunFd = vpnInterface?.fd ?: 0
|
||||
val dialerAddr = if (currentConfig?.browserDialerMode.isNullOrEmpty()) {
|
||||
""
|
||||
} else {
|
||||
val dialerMode = BrowserDialerMode.from(config.browserDialerMode)
|
||||
val dialerAddr = if (dialerMode != null) {
|
||||
"127.0.0.1:${Utils.findRandomFreePort()}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
tunFd = 0
|
||||
}
|
||||
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
|
||||
if (dialerAddr.isNotNullEmpty()) {
|
||||
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
|
||||
}
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
|
||||
if (!coreController.isRunning) {
|
||||
if (!isRunning()) {
|
||||
error("Core failed to start")
|
||||
}
|
||||
|
||||
@@ -276,15 +156,23 @@ object CoreServiceManager {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
if (config.browserDialerMode == "OkHttp") {
|
||||
browserDialer = DialerNativeService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
} else if (config.browserDialerMode == "WebView") {
|
||||
browserDialer = DialerWebviewService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
when (dialerMode) {
|
||||
BrowserDialerMode.OKHTTP -> {
|
||||
browserDialer = DialerNativeService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
}
|
||||
|
||||
BrowserDialerMode.WEBVIEW -> {
|
||||
browserDialer = DialerWebviewService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
if (!isReload) {
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
}
|
||||
NotificationManager.startSpeedNotification()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||
}
|
||||
@@ -297,7 +185,11 @@ object CoreServiceManager {
|
||||
fun stopCoreLoop(): Boolean {
|
||||
val service = getService() ?: return false
|
||||
|
||||
if (coreController.isRunning) {
|
||||
networkMonitor?.unregister()
|
||||
networkMonitor = null
|
||||
currentVpnInterface = null
|
||||
|
||||
if (isRunning()) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
coreController.stopLoop()
|
||||
@@ -314,7 +206,7 @@ object CoreServiceManager {
|
||||
browserDialer = null
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
NotificationManager.cancelNotification()
|
||||
|
||||
try {
|
||||
@@ -326,11 +218,83 @@ object CoreServiceManager {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to upstream network changes for whichever run mode is active.
|
||||
* All three services share this manager, so the tunnel recovers from a handover in proxy only
|
||||
* and root mode as well, not just behind the VPN interface.
|
||||
*/
|
||||
private fun startNetworkMonitor(service: Service) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
|
||||
if (networkMonitor != null) return
|
||||
|
||||
val connectivity = service.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||
networkMonitor = NetworkMonitor(
|
||||
connectivity = connectivity,
|
||||
onUnderlyingNetworksChanged = { networks -> serviceControl?.get()?.setUnderlyingNetworks(networks) },
|
||||
onHandover = { reloadCore() },
|
||||
).also { it.register() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the core in place after the upstream network changed: the service, the notification
|
||||
* and the VPN interface all stay up, so nothing of this is visible.
|
||||
*
|
||||
* The config is rebuilt on purpose, outbound server domains are resolved while building it and
|
||||
* an address resolved on a network that is gone can be unusable on the new one.
|
||||
*
|
||||
* @return True if the core is running again.
|
||||
*/
|
||||
private fun reloadCore(): Boolean {
|
||||
if (isReloading) return false
|
||||
val service = getService() ?: return false
|
||||
if (!isRunning()) return false
|
||||
|
||||
return try {
|
||||
val tunFd = tunFdForCore()
|
||||
|
||||
isReloading = true
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
|
||||
|
||||
coreController.stopLoop()
|
||||
launchCore(service, tunFd, isReload = true)
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload finished")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to reload core: $message", e)
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
|
||||
false
|
||||
} finally {
|
||||
isReloading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tun descriptor to hand to the core on a reload.
|
||||
*
|
||||
* With hev-socks5-tunnel the core is started without a tun and never touches the descriptor.
|
||||
* Otherwise it closes the one it was given when it stops, which would take the VPN interface
|
||||
* down, so it gets a duplicate to close instead.
|
||||
*/
|
||||
private fun tunFdForCore(): ParcelFileDescriptor? {
|
||||
val vpnInterface = currentVpnInterface ?: return null
|
||||
return try {
|
||||
if (SettingsManager.isUsingHevTun()) vpnInterface else vpnInterface.dup()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to duplicate VPN interface", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries and resets all outbound traffic counters in one core call.
|
||||
* Go side format: tag,direction,value;tag,direction,value;
|
||||
*/
|
||||
fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> {
|
||||
// The stats manager is gone once the core stops, querying it then reaches into freed state.
|
||||
if (!isRunning()) return emptyList()
|
||||
|
||||
val payload = coreController.queryAllOutboundTrafficStats()
|
||||
|
||||
val result = ArrayList<OutboundTrafficStat>()
|
||||
@@ -361,7 +325,7 @@ object CoreServiceManager {
|
||||
* Also fetches remote IP information if the delay test was successful.
|
||||
*/
|
||||
private fun measureV2rayDelay() {
|
||||
if (coreController.isRunning == false) {
|
||||
if (!isRunning()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,12 +354,12 @@ object CoreServiceManager {
|
||||
} else {
|
||||
service.getString(R.string.connection_test_error, errorStr)
|
||||
}
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
|
||||
MessageHelper.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")
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -500,10 +464,10 @@ object CoreServiceManager {
|
||||
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, "")
|
||||
if (isRunning()) {
|
||||
MessageHelper.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
|
||||
} else {
|
||||
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
|
||||
MessageHelper.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,7 +488,7 @@ object CoreServiceManager {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
startVService(serviceControl.getService())
|
||||
LauncherManager.startService(serviceControl.getService())
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_DELAY -> {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||
import com.v2ray.ang.service.CoreRootService
|
||||
import com.v2ray.ang.service.CoreVpnService
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object LauncherManager {
|
||||
|
||||
fun startServiceFromToggle(context: Context): Boolean {
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
context.toast(R.string.app_tile_first_use)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun startService(context: Context, guid: String? = null) {
|
||||
LogUtil.i(AppConfig.TAG, "LauncherManager: startService from ${context::class.java.simpleName}")
|
||||
|
||||
if (guid != null) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
//context.toast(R.string.toast_services_stop)
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun startContextService(context: Context) {
|
||||
// Note: isRunning check is removed here to avoid loading Native libraries in the UI process.
|
||||
// The check is performed in CoreServiceManager when the service starts in the daemon process.
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: No server selected")
|
||||
error(context.getString(R.string.app_tile_first_use))
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: Failed to decode server config")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
if (!config.configType.isComplexType()
|
||||
&& !Utils.isValidUrl(config.server)
|
||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: Invalid server configuration")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
SettingsManager.refreshRuntimeSocksPort()
|
||||
|
||||
if (config.insecure == true && config.pinnedCA256.isNullOrEmpty()) {
|
||||
context.toastError(R.string.toast_allow_insecure_deprecated)
|
||||
Utils.setClipboard(context, context.getString(R.string.toast_allow_insecure_deprecated))
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||
} else {
|
||||
context.toast(R.string.toast_services_start)
|
||||
}
|
||||
|
||||
val isRootMode = SettingsManager.isRootMode()
|
||||
if (isRootMode && !RootManager.isRootAvailable()) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: root mode requires root but none available")
|
||||
error(context.getString(R.string.toast_root_required))
|
||||
}
|
||||
|
||||
val intent = if (isRootMode) {
|
||||
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting Root service")
|
||||
Intent(context.applicationContext, CoreRootService::class.java)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting VPN service")
|
||||
Intent(context.applicationContext, CoreVpnService::class.java)
|
||||
} else {
|
||||
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting Proxy service")
|
||||
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
|
||||
}
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: SecurityException) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: Missing permission to start foreground service", e)
|
||||
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||
} catch (e: RuntimeException) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: Foreground service start not allowed", e)
|
||||
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class LocateTarget(
|
||||
val groupId: String,
|
||||
val groupIndex: Int,
|
||||
val itemPosition: Int
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class SubscriptionUpdateMessage(
|
||||
val key: Int,
|
||||
val forcedUpdate: Boolean,
|
||||
val subIds: List<String> = listOf()
|
||||
) : Serializable
|
||||
@@ -5,6 +5,7 @@ import java.io.Serializable
|
||||
data class TestServiceMessage(
|
||||
val key: Int,
|
||||
val subscriptionId: String = "",
|
||||
val serverGuids: List<String> = emptyList()
|
||||
val serverGuids: List<String> = emptyList(),
|
||||
val onlyTcp: Boolean = false
|
||||
) : Serializable
|
||||
|
||||
@@ -6,5 +6,6 @@ data class UrlContentRequest(
|
||||
val httpPort: Int = 0,
|
||||
val proxyUsername: String? = null,
|
||||
val proxyPassword: String? = null,
|
||||
val userAgent: String? = null
|
||||
val userAgent: String? = null,
|
||||
val requestHeaders: String? = null
|
||||
)
|
||||
@@ -72,65 +72,34 @@ data class V2rayConfig(
|
||||
var mux: MuxBean? = MuxBean(false)
|
||||
) {
|
||||
data class OutSettingsBean(
|
||||
var vnext: List<VnextBean>? = null,
|
||||
var servers: List<ServersBean>? = null,
|
||||
/*Blackhole*/
|
||||
var response: Response? = null,
|
||||
/*DNS*/
|
||||
val network: String? = null,
|
||||
/*Common */
|
||||
var address: Any? = null,
|
||||
var port: Int? = null,
|
||||
/*Freedom*/
|
||||
val redirect: String? = null,
|
||||
val userLevel: Int? = null,
|
||||
/*Loopback*/
|
||||
val inboundTag: String? = null,
|
||||
var level: Int? = null,
|
||||
var email: String? = null,
|
||||
/*HTTP/SOCKS*/
|
||||
var user: String? = null,
|
||||
var pass: String? = null,
|
||||
var headers: Map<String, String>? = null,
|
||||
/*VMess/VLESS*/
|
||||
var id: String? = null,
|
||||
var security: String? = null,
|
||||
var encryption: String? = null,
|
||||
/*VLESS*/
|
||||
var flow: String? = null,
|
||||
/*Trojan/Shadowsocks*/
|
||||
var password: String? = null,
|
||||
/*Shadowsocks*/
|
||||
var method: String? = null,
|
||||
/*Hysteria/Hysteria2*/
|
||||
var version: Int? = null,
|
||||
/*Wireguard*/
|
||||
var secretKey: String? = null,
|
||||
val peers: List<WireGuardBean>? = null,
|
||||
var reserved: List<Int>? = null,
|
||||
var mtu: Int? = null,
|
||||
var obfsPassword: String? = null,
|
||||
var version: Int? = null,
|
||||
var domainStrategy: String? = null,
|
||||
) {
|
||||
|
||||
data class VnextBean(
|
||||
var address: String = "",
|
||||
var port: Int = AppConfig.DEFAULT_PORT,
|
||||
var users: List<UsersBean>
|
||||
) {
|
||||
|
||||
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 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<SocksUsersBean>? = 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,
|
||||
@@ -139,7 +108,7 @@ data class V2rayConfig(
|
||||
}
|
||||
|
||||
data class StreamSettingsBean(
|
||||
var network: String = AppConfig.DEFAULT_NETWORK,
|
||||
var network: String? = AppConfig.DEFAULT_NETWORK,
|
||||
var security: String? = null,
|
||||
var tcpSettings: TcpSettingsBean? = null,
|
||||
var kcpSettings: KcpSettingsBean? = null,
|
||||
@@ -339,45 +308,19 @@ data class V2rayConfig(
|
||||
)
|
||||
|
||||
fun getServerAddress(): String? {
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.firstOrNull()?.address ?: settings?.address as? String
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
) {
|
||||
return settings?.servers?.firstOrNull()?.address
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
return settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
|
||||
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
return settings?.address as? String
|
||||
return if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
|
||||
} else {
|
||||
settings?.address as? String
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getServerPort(): Int? {
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.firstOrNull()?.port ?: settings?.port
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
) {
|
||||
return settings?.servers?.firstOrNull()?.port
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
return settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toInt()
|
||||
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
return settings?.port
|
||||
return if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toIntOrNull()
|
||||
} else {
|
||||
settings?.port
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
|
||||
@@ -494,6 +437,7 @@ data class V2rayConfig(
|
||||
data class PingConfigObject(
|
||||
val destination: String,
|
||||
val connectivity: String? = null,
|
||||
val httpMethod: String? = null,
|
||||
val interval: String,
|
||||
val sampling: Int,
|
||||
val timeout: String? = null
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.BalancerStrategyType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
|
||||
@Suppress("PropertyName")
|
||||
data class V2rayNShareItem(
|
||||
// val IndexId: String?,
|
||||
val ConfigType: Int?,
|
||||
// val CoreType: Int?,
|
||||
val ConfigVersion: Int?,
|
||||
val Subid: String?,
|
||||
val IsSub: Boolean?,
|
||||
val PreSocksPort: Int?,
|
||||
// val DisplayLog: Boolean?,
|
||||
val Remarks: String?,
|
||||
val Address: String?,
|
||||
val Port: Int?,
|
||||
val Password: String?,
|
||||
val Username: String?,
|
||||
val Network: String?,
|
||||
val StreamSecurity: String?,
|
||||
val AllowInsecure: String?,
|
||||
val Sni: String?,
|
||||
val Alpn: String?,
|
||||
val Fingerprint: String?,
|
||||
val PublicKey: String?,
|
||||
val ShortId: String?,
|
||||
val SpiderX: String?,
|
||||
val Mldsa65Verify: String?,
|
||||
// val MuxEnabled: Boolean?,
|
||||
// val Cert: String?,
|
||||
val CertSha: String?,
|
||||
val EchConfigList: String?,
|
||||
val VerifyPeerCertByName: String?,
|
||||
val Finalmask: String?,
|
||||
val ProtoExtraObj: V2rayNProtocolExtraShareItem?,
|
||||
val TransportExtraObj: V2rayNTransportExtraShareItem?,
|
||||
) {
|
||||
data class V2rayNProtocolExtraShareItem(
|
||||
val AlterId: Int?,
|
||||
val VmessSecurity: String?,
|
||||
val Flow: String?,
|
||||
val VlessEncryption: String?,
|
||||
val SsMethod: String?,
|
||||
val WgPublicKey: String?,
|
||||
val WgPresharedKey: String?,
|
||||
val WgInterfaceAddress: String?,
|
||||
val WgReserved: String?,
|
||||
val WgMtu: Int?,
|
||||
val SalamanderPass: String?,
|
||||
val UpMbps: Int?,
|
||||
val DownMbps: Int?,
|
||||
val Ports: String?,
|
||||
val HopInterval: String?,
|
||||
val GroupType: String?,
|
||||
val ChildItems: String?,
|
||||
val SubChildItems: String?,
|
||||
val Filter: String?,
|
||||
val MultipleLoad: Int?,
|
||||
)
|
||||
|
||||
data class V2rayNTransportExtraShareItem(
|
||||
val RawHeaderType: String?,
|
||||
val Host: String?,
|
||||
val Path: String?,
|
||||
val XhttpMode: String?,
|
||||
val XhttpExtra: String?,
|
||||
val GrpcAuthority: String?,
|
||||
val GrpcServiceName: String?,
|
||||
val GrpcMode: String?,
|
||||
val KcpHeaderType: String?,
|
||||
val KcpSeed: String?,
|
||||
val KcpMtu: Int?,
|
||||
)
|
||||
|
||||
fun toProfileItem(): ProfileItem {
|
||||
val configType = when (ConfigType) {
|
||||
1 -> EConfigType.VMESS
|
||||
2 -> EConfigType.CUSTOM
|
||||
3 -> EConfigType.SHADOWSOCKS
|
||||
4 -> EConfigType.SOCKS
|
||||
5 -> EConfigType.VLESS
|
||||
6 -> EConfigType.TROJAN
|
||||
7 -> EConfigType.HYSTERIA2
|
||||
// 8 -> EConfigType.TUIC
|
||||
9 -> EConfigType.WIREGUARD
|
||||
10 -> EConfigType.HTTP
|
||||
101 -> EConfigType.POLICYGROUP
|
||||
102 -> EConfigType.PROXYCHAIN
|
||||
else -> error("Unknown ConfigType: $ConfigType")
|
||||
}
|
||||
val network = if (configType == EConfigType.HYSTERIA2) "hysteria" else
|
||||
when (Network) {
|
||||
"raw" -> "tcp"
|
||||
else -> NetworkType.fromString(Network).type
|
||||
}
|
||||
val profile = ProfileItem(
|
||||
configType = configType,
|
||||
remarks = Remarks.orEmpty(),
|
||||
server = Address.orEmpty(),
|
||||
serverPort = Port.toString(),
|
||||
password = Password.orEmpty(),
|
||||
method = when (configType) {
|
||||
EConfigType.VMESS -> ProtoExtraObj?.VmessSecurity.orEmpty()
|
||||
EConfigType.SHADOWSOCKS -> ProtoExtraObj?.SsMethod.orEmpty()
|
||||
EConfigType.VLESS -> ProtoExtraObj?.VlessEncryption.orEmpty()
|
||||
else -> null
|
||||
},
|
||||
flow = ProtoExtraObj?.Flow.orEmpty(),
|
||||
username = Username.orEmpty(),
|
||||
network = network,
|
||||
headerType = when (network) {
|
||||
NetworkType.TCP.type -> TransportExtraObj?.RawHeaderType
|
||||
NetworkType.KCP.type -> TransportExtraObj?.KcpHeaderType
|
||||
else -> null
|
||||
},
|
||||
host = TransportExtraObj?.Host,
|
||||
path = TransportExtraObj?.Path,
|
||||
seed = TransportExtraObj?.KcpSeed,
|
||||
kcpMtu = TransportExtraObj?.KcpMtu,
|
||||
mode = TransportExtraObj?.GrpcMode,
|
||||
serviceName = TransportExtraObj?.GrpcServiceName,
|
||||
authority = TransportExtraObj?.GrpcAuthority,
|
||||
xhttpMode = TransportExtraObj?.XhttpMode,
|
||||
xhttpExtra = TransportExtraObj?.XhttpExtra,
|
||||
finalMask = Finalmask,
|
||||
security = StreamSecurity,
|
||||
sni = Sni,
|
||||
alpn = Alpn,
|
||||
fingerPrint = Fingerprint,
|
||||
insecure = AllowInsecure?.toBoolean(),
|
||||
echConfigList = EchConfigList,
|
||||
verifyPeerCertByName = VerifyPeerCertByName,
|
||||
pinnedCA256 = CertSha,
|
||||
publicKey = PublicKey,
|
||||
shortId = ShortId,
|
||||
spiderX = SpiderX,
|
||||
mldsa65Verify = Mldsa65Verify,
|
||||
secretKey = if (configType == EConfigType.WIREGUARD) Password else null,
|
||||
preSharedKey = ProtoExtraObj?.WgPresharedKey,
|
||||
localAddress = ProtoExtraObj?.WgInterfaceAddress,
|
||||
reserved = ProtoExtraObj?.WgReserved,
|
||||
mtu = ProtoExtraObj?.WgMtu,
|
||||
obfsPassword = ProtoExtraObj?.SalamanderPass,
|
||||
portHopping = ProtoExtraObj?.Ports,
|
||||
portHoppingInterval = ProtoExtraObj?.HopInterval,
|
||||
bandwidthDown = ProtoExtraObj?.DownMbps?.takeIf { it > 0 }?.let { "${it}Mbps" },
|
||||
bandwidthUp = ProtoExtraObj?.UpMbps?.takeIf { it > 0 }?.let { "${it}Mbps" },
|
||||
policyGroupType = when (ProtoExtraObj?.MultipleLoad) {
|
||||
2 -> BalancerStrategyType.RANDOM.policyGroupType
|
||||
3 -> BalancerStrategyType.ROUND_ROBIN.policyGroupType
|
||||
4 -> BalancerStrategyType.LEAST_LOAD.policyGroupType
|
||||
else -> BalancerStrategyType.LEAST_PING.policyGroupType
|
||||
},
|
||||
// NOTE: not safe, suggest converting and rewriting
|
||||
// policyGroupSubscriptionId = ProtoExtraObj?.SubChildItems,
|
||||
policyGroupSubscriptionId = if (ProtoExtraObj?.SubChildItems == "self") "self" else null,
|
||||
policyGroupFilter = ProtoExtraObj?.Filter,
|
||||
// NOTE: proxyChainProfiles stores remarks, not IndexId
|
||||
// proxyChainProfiles = ProtoExtraObj?.ChildItems,
|
||||
)
|
||||
// profile.description =
|
||||
return profile
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ data class ProfileItem(
|
||||
var xhttpMode: String? = null,
|
||||
var xhttpExtra: String? = null,
|
||||
var finalMask: String? = null,
|
||||
|
||||
var security: String? = null,
|
||||
var sni: String? = null,
|
||||
var alpn: String? = null,
|
||||
@@ -59,6 +60,7 @@ data class ProfileItem(
|
||||
var obfsPassword: String? = null,
|
||||
var portHopping: String? = null,
|
||||
var portHoppingInterval: String? = null,
|
||||
@Deprecated("Use pinnedCA256")
|
||||
var pinSHA256: String? = null,
|
||||
var bandwidthDown: String? = null,
|
||||
var bandwidthUp: String? = null,
|
||||
@@ -66,66 +68,45 @@ data class ProfileItem(
|
||||
var policyGroupType: String? = null,
|
||||
var policyGroupSubscriptionId: String? = null,
|
||||
var policyGroupFilter: String? = null,
|
||||
var policyGroupTestOutbounds: Boolean? = null,
|
||||
var policyGroupFallbackTag: String? = null,
|
||||
var proxyChainProfiles: String? = null,
|
||||
|
||||
var browserDialerMode: String? = null,
|
||||
) {
|
||||
|
||||
) {
|
||||
companion object {
|
||||
fun create(configType: EConfigType): ProfileItem {
|
||||
return ProfileItem(configType = configType)
|
||||
}
|
||||
fun create(configType: EConfigType): ProfileItem =
|
||||
ProfileItem(configType = configType)
|
||||
}
|
||||
|
||||
fun getServerAddressAndPort(): String {
|
||||
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
||||
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
|
||||
}
|
||||
return Utils.getIpv6Address(server) + ":" + serverPort
|
||||
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.kcpMtu == obj.kcpMtu
|
||||
&& this.kcpTti == obj.kcpTti
|
||||
&& 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.pinnedCA256 == obj.pinnedCA256
|
||||
&& this.proxyChainProfiles == obj.proxyChainProfiles
|
||||
)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Dedicated identity for "remove duplicate configurations".
|
||||
*
|
||||
* Ignores metadata that does not affect connection:
|
||||
* - configVersion
|
||||
* - subscriptionId
|
||||
* - addedTime
|
||||
* - remarks
|
||||
* - description
|
||||
*
|
||||
* All other fields, including configType, are included in the comparison.
|
||||
*
|
||||
* Returns a copy; the caller must not modify it further.
|
||||
*/
|
||||
fun duplicateIdentity(): ProfileItem =
|
||||
copy(
|
||||
configVersion = 0,
|
||||
subscriptionId = "",
|
||||
addedTime = 0L,
|
||||
remarks = "",
|
||||
description = null
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class RulesetItem(
|
||||
var id: String = "",
|
||||
var remarks: String? = "",
|
||||
var ip: List<String>? = null,
|
||||
var domain: List<String>? = null,
|
||||
|
||||
@@ -2,5 +2,7 @@ package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServersCache(
|
||||
val guid: String,
|
||||
val profile: ProfileItem
|
||||
)
|
||||
val profile: ProfileItem,
|
||||
val testDelayMillis: Long = 0L,
|
||||
val testDelayString: String = "",
|
||||
)
|
||||
@@ -13,5 +13,6 @@ data class SubscriptionItem(
|
||||
var filter: String? = null,
|
||||
var allowInsecureUrl: Boolean = false,
|
||||
var userAgent: String? = null,
|
||||
var requestHeaders: String? = null,
|
||||
)
|
||||
|
||||
@@ -7,20 +7,22 @@ package com.v2ray.ang.enums
|
||||
* @param policyGroupTypeValue The numeric string stored in [com.v2ray.ang.dto.entities.ProfileItem.policyGroupType].
|
||||
* @param requiresBurstObservatory Whether this strategy needs a burstObservatory (leastLoad).
|
||||
* @param requiresObservatory Whether this strategy needs an observatory (leastPing).
|
||||
* @param supportsObservatory Whether an observatory can health-filter this strategy.
|
||||
*/
|
||||
enum class BalancerStrategyType(
|
||||
val policyGroupType: String,
|
||||
val policyGroupTypeValue: String,
|
||||
val requiresBurstObservatory: Boolean = false,
|
||||
val requiresObservatory: Boolean = false,
|
||||
val supportsObservatory: Boolean = false,
|
||||
) {
|
||||
LEAST_LOAD("leastLoad", "1", requiresBurstObservatory = true),
|
||||
RANDOM("random", "2"),
|
||||
ROUND_ROBIN("roundRobin", "3"),
|
||||
RANDOM("random", "2", supportsObservatory = true),
|
||||
ROUND_ROBIN("roundRobin", "3", supportsObservatory = true),
|
||||
LEAST_PING("leastPing", "", requiresObservatory = true); // default / else
|
||||
|
||||
companion object {
|
||||
fun from(policyGroupType: String?): BalancerStrategyType =
|
||||
entries.firstOrNull { it.policyGroupTypeValue == policyGroupType } ?: LEAST_PING
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class BrowserDialerMode(val value: String) {
|
||||
OKHTTP("OkHttp"),
|
||||
WEBVIEW("WebView");
|
||||
|
||||
companion object {
|
||||
fun from(value: String?): BrowserDialerMode? = entries.find { it.value == value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.v2ray.ang.extension
|
||||
|
||||
/**
|
||||
* Moves an item to another index while preserving the relative order of the remaining items.
|
||||
*/
|
||||
internal fun <T> MutableList<T>.moveItem(fromIndex: Int, toIndex: Int): Boolean {
|
||||
if (fromIndex !in indices || toIndex !in indices || fromIndex == toIndex) return false
|
||||
add(toIndex, removeAt(fromIndex))
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.v2ray.ang.extension
|
||||
|
||||
/**
|
||||
* Removes all whitespace from the string.
|
||||
*
|
||||
* @return The string without whitespace.
|
||||
*/
|
||||
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
|
||||
|
||||
/**
|
||||
* Returns null if the string is null or blank, otherwise returns the string itself.
|
||||
*
|
||||
* @return The string or null.
|
||||
*/
|
||||
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Converts the string to a Long value, or returns 0 if the conversion fails.
|
||||
*
|
||||
* @return The Long value.
|
||||
*/
|
||||
fun String.toLongEx(): Long = toLongOrNull() ?: 0
|
||||
|
||||
/**
|
||||
* 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.isNullOrBlank()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to match text either by Regex or literal string.
|
||||
*/
|
||||
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
|
||||
if (keyword.isNullOrEmpty()) {
|
||||
return true
|
||||
}
|
||||
return regex?.containsMatchIn(this)
|
||||
?: this.contains(keyword, ignoreCase = ignoreCase)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.v2ray.ang.extension
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import com.v2ray.ang.ui.compose.AppSnackbarManager
|
||||
import com.v2ray.ang.ui.compose.ToastType
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: Int) {
|
||||
val text = getString(message)
|
||||
dispatchMessage(text, ToastType.NORMAL) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: CharSequence) {
|
||||
dispatchMessage(message, ToastType.NORMAL) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastSuccess(message: Int) {
|
||||
val text = getString(message)
|
||||
dispatchMessage(text, ToastType.SUCCESS) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastSuccess(message: CharSequence) {
|
||||
dispatchMessage(message, ToastType.SUCCESS) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastError(message: Int) {
|
||||
val text = getString(message)
|
||||
dispatchMessage(text, ToastType.ERROR) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastError(message: CharSequence) {
|
||||
dispatchMessage(message, ToastType.ERROR) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an info toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastInfo(message: Int) {
|
||||
val text = getString(message)
|
||||
dispatchMessage(text, ToastType.INFO) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an info toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastInfo(message: CharSequence) {
|
||||
dispatchMessage(message, ToastType.INFO) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun runOnMain(crossinline block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post { block() }
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun dispatchMessage(
|
||||
message: CharSequence,
|
||||
type: ToastType,
|
||||
long: Boolean = false,
|
||||
crossinline fallback: () -> Unit
|
||||
) {
|
||||
val handledBySnackbar = AppSnackbarManager.show(
|
||||
message = message,
|
||||
type = type,
|
||||
long = long
|
||||
)
|
||||
if (!handledBySnackbar) {
|
||||
runOnMain { fallback() }
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,13 @@
|
||||
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 com.v2ray.ang.enums.EConfigType
|
||||
import es.dmoral.toasty.Toasty
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const val THRESHOLD = 1000L
|
||||
const val DIVISOR = 1024.0
|
||||
|
||||
@@ -100,56 +37,6 @@ fun Long.toTrafficString(): String {
|
||||
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(" ", "")
|
||||
|
||||
/**
|
||||
* Returns null if the string is null or blank, otherwise returns the string itself.
|
||||
*
|
||||
* @return The string or null.
|
||||
*/
|
||||
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Converts the string to a Long value, or returns 0 if the conversion fails.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
@@ -172,37 +59,6 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
|
||||
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.isNullOrBlank()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to match text either by Regex or literal string.
|
||||
*/
|
||||
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
|
||||
if (keyword.isNullOrEmpty()) {
|
||||
return true
|
||||
}
|
||||
return regex?.containsMatchIn(this)
|
||||
?: this.contains(keyword, ignoreCase = ignoreCase)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the config type is a group type (PolicyGroup or ProxyChain).
|
||||
*
|
||||
|
||||
@@ -36,9 +36,6 @@ object Hysteria2Fmt : FmtBase() {
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
config.obfsPassword = queryParam["obfs-password"]
|
||||
config.portHopping = queryParam["mport"]
|
||||
if (config.portHopping.isNotNullEmpty()) {
|
||||
config.portHoppingInterval = queryParam["mportHopInt"]
|
||||
}
|
||||
config.pinnedCA256 = queryParam["pinSHA256"]
|
||||
|
||||
}
|
||||
@@ -67,39 +64,6 @@ object Hysteria2Fmt : FmtBase() {
|
||||
if (config.portHopping.isNotNullEmpty()) {
|
||||
dicQuery["mport"] = config.portHopping.orEmpty()
|
||||
}
|
||||
if (config.portHoppingInterval.isNotNullEmpty()) {
|
||||
val rawInterval = config.portHoppingInterval?.trim().nullIfBlank()
|
||||
val interval = if (rawInterval == null) {
|
||||
null
|
||||
} else {
|
||||
val singleValue = rawInterval.toIntOrNull()
|
||||
if (singleValue != null) {
|
||||
if (singleValue < 5) {
|
||||
null
|
||||
} else {
|
||||
rawInterval
|
||||
}
|
||||
} else {
|
||||
val parts = rawInterval.split('-')
|
||||
if (parts.size == 2) {
|
||||
val start = parts[0].trim().toIntOrNull()
|
||||
val end = parts[1].trim().toIntOrNull()
|
||||
if (start != null && end != null) {
|
||||
val minStart = maxOf(5, start)
|
||||
val minEnd = maxOf(minStart, end)
|
||||
(minStart + minEnd) / 2
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (interval != null) {
|
||||
dicQuery["mportHopInt"] = interval.toString()
|
||||
}
|
||||
}
|
||||
if (config.pinnedCA256.isNotNullEmpty()) {
|
||||
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.V2rayNShareItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object V2rayNFmt : FmtBase() {
|
||||
fun parse(str: String): ProfileItem? {
|
||||
try {
|
||||
val jsonBase64Payload = str.substringAfterLast('/')
|
||||
val jsonPayload = Utils.decode(jsonBase64Payload)
|
||||
val v2rayNShareItem = JsonUtil.fromJson(jsonPayload, V2rayNShareItem::class.java)
|
||||
return v2rayNShareItem?.toProfileItem()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN format", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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.V2rayNFmt
|
||||
import com.v2ray.ang.fmt.VlessFmt
|
||||
import com.v2ray.ang.fmt.VmessFmt
|
||||
import com.v2ray.ang.fmt.WireguardFmt
|
||||
@@ -42,7 +43,8 @@ object AngConfigManager {
|
||||
EConfigType.VLESS.protocolScheme to VlessFmt::parse,
|
||||
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
|
||||
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
|
||||
AppConfig.HY2 to Hysteria2Fmt::parse
|
||||
AppConfig.HY2 to Hysteria2Fmt::parse,
|
||||
AppConfig.V2RAYNFMTS to V2rayNFmt::parse
|
||||
)
|
||||
}
|
||||
|
||||
@@ -494,6 +496,12 @@ object AngConfigManager {
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
|
||||
if (str.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)
|
||||
&& config.policyGroupSubscriptionId == "self"
|
||||
) {
|
||||
config.policyGroupSubscriptionId = subid
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
@@ -550,6 +558,7 @@ object AngConfigManager {
|
||||
}
|
||||
LogUtil.i(AppConfig.TAG, url)
|
||||
val userAgent = it.subscription.userAgent
|
||||
val requestHeaders = it.subscription.requestHeaders
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
|
||||
@@ -559,6 +568,7 @@ object AngConfigManager {
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent,
|
||||
requestHeaders = requestHeaders,
|
||||
timeout = 15000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
@@ -574,7 +584,8 @@ object AngConfigManager {
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent
|
||||
userAgent = userAgent,
|
||||
requestHeaders = requestHeaders
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -605,6 +616,41 @@ object AngConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes invalid server configurations for a subscription.
|
||||
*
|
||||
* @param subId The subscription ID.
|
||||
*/
|
||||
fun removeInvalidServer(subId: String) {
|
||||
val serverList = MmkvManager.decodeServerList(subId)
|
||||
val invalidServers = serverList.filter {
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(it)
|
||||
aff != null && aff.testDelayMillis < 0L
|
||||
}
|
||||
MmkvManager.removeServers(invalidServers, subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts servers by test results for a subscription.
|
||||
*
|
||||
* @param subId The subscription ID.
|
||||
*/
|
||||
fun sortByTestResultsForSub(subId: String) {
|
||||
val serverList = MmkvManager.decodeServerList(subId)
|
||||
if (serverList.isEmpty()) return
|
||||
|
||||
val sorted = serverList
|
||||
.map { guid ->
|
||||
val delay =
|
||||
MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
|
||||
guid to if (delay <= 0L) Long.MAX_VALUE else delay
|
||||
}
|
||||
.sortedBy { it.second }
|
||||
.map { it.first }
|
||||
.toMutableList()
|
||||
MmkvManager.encodeServerList(sorted, subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the configuration via a subscription.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
@@ -14,6 +20,9 @@ import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
|
||||
object MmkvManager {
|
||||
|
||||
@@ -233,6 +242,31 @@ object MmkvManager {
|
||||
encodeServerList(serverList, subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes multiple server configurations from a subscription.
|
||||
*
|
||||
* @param guids The list of server GUIDs.
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun removeServers(guids: List<String>, subscriptionId: String) {
|
||||
if (guids.isEmpty()) return
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
if (serverList.removeAll(guids)) {
|
||||
encodeServerList(serverList, subId)
|
||||
}
|
||||
|
||||
val selectedServer = getSelectServer()
|
||||
guids.forEach { guid ->
|
||||
if (selectedServer == guid) {
|
||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||
}
|
||||
profileFullStorage.remove(guid)
|
||||
serverAffStorage.remove(guid)
|
||||
serverRawStorage.remove(guid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the server affiliation information.
|
||||
*
|
||||
@@ -722,4 +756,56 @@ object MmkvManager {
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Compose helpers for Settings
|
||||
|
||||
/**
|
||||
* MMKV-backed String state, auto-persists and notifies on change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMmkvString(
|
||||
key: String,
|
||||
default: String = ""
|
||||
): MutableState<String> {
|
||||
val state = remember(key) {
|
||||
mutableStateOf(decodeSettingsString(key, default) ?: default)
|
||||
}
|
||||
|
||||
LaunchedEffect(key) {
|
||||
snapshotFlow { state.value }
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { value ->
|
||||
encodeSettings(key, value)
|
||||
SettingsChangeManager.notifySettingChanged(key)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* MMKV-backed Boolean state, auto-persists and notifies on change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMmkvBool(
|
||||
key: String,
|
||||
default: Boolean = false
|
||||
): MutableState<Boolean> {
|
||||
val state = remember(key) {
|
||||
mutableStateOf(decodeSettingsBool(key, default))
|
||||
}
|
||||
|
||||
LaunchedEffect(key) {
|
||||
snapshotFlow { state.value }
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { value ->
|
||||
encodeSettings(key, value)
|
||||
SettingsChangeManager.notifySettingChanged(key)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.toSpeedString
|
||||
import com.v2ray.ang.ui.MainActivity
|
||||
import com.v2ray.ang.ui.main.MainActivity
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -93,8 +93,8 @@ object NotificationManager {
|
||||
|
||||
mBuilder = NotificationCompat.Builder(service, channelId)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setContentTitle(currentConfig?.remarks)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setContentTitle(currentConfig?.remarks ?: service.getString(R.string.app_name))
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
@@ -115,6 +115,17 @@ object NotificationManager {
|
||||
service.startForeground(NOTIFICATION_ID, mBuilder?.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fulfills or refreshes the foreground-service contract before a start command can
|
||||
* return early. A duplicate startForegroundService call still requires the service
|
||||
* to enter foreground state promptly, even when the core is already running.
|
||||
*/
|
||||
fun ensureForeground() {
|
||||
val service = getService() ?: return
|
||||
val notification = mBuilder?.build()
|
||||
if (notification == null) showNotification(null) else service.startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the notification.
|
||||
*/
|
||||
@@ -147,12 +158,9 @@ object NotificationManager {
|
||||
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
|
||||
)
|
||||
// Foreground-service notifications must remain visible; LOW is silent but valid.
|
||||
val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
|
||||
chan.lightColor = Color.DKGRAY
|
||||
chan.importance = NotificationManager.IMPORTANCE_NONE
|
||||
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
getNotificationManager()?.createNotificationChannel(chan)
|
||||
return channelId
|
||||
@@ -278,4 +286,4 @@ object NotificationManager {
|
||||
private fun getService(): Service? {
|
||||
return CoreServiceManager.serviceControl?.get()?.getService()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,57 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import com.v2ray.ang.AppConfig
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Manages global flags for actions triggered by setting changes.
|
||||
* Uses AtomicBoolean for thread-safe consume operations.
|
||||
*/
|
||||
object SettingsChangeManager {
|
||||
private val _restartService = MutableStateFlow(false)
|
||||
private val _setupGroupTab = MutableStateFlow(false)
|
||||
|
||||
// Mark restartService as requiring a restart
|
||||
private val restartService = AtomicBoolean(false)
|
||||
private val setupGroupTab = AtomicBoolean(false)
|
||||
|
||||
// Keys that affect only UI behavior and do not require core service restart.
|
||||
private val uiOnlyKeys = setOf(
|
||||
AppConfig.PREF_CONFIRM_REMOVE,
|
||||
AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
|
||||
AppConfig.PREF_GROUP_ALL_DISPLAY,
|
||||
AppConfig.PREF_LANGUAGE,
|
||||
AppConfig.PREF_UI_MODE_NIGHT,
|
||||
AppConfig.PREF_IS_BOOTED,
|
||||
)
|
||||
|
||||
/**
|
||||
* Called when a setting value changes.
|
||||
* Triggers service restart if the key is not UI-only, and always refreshes UI tabs.
|
||||
*/
|
||||
fun notifySettingChanged(key: String) {
|
||||
if (key !in uiOnlyKeys) {
|
||||
makeRestartService()
|
||||
}
|
||||
makeSetupGroupTab()
|
||||
}
|
||||
|
||||
fun makeRestartService() {
|
||||
_restartService.value = true
|
||||
restartService.set(true)
|
||||
}
|
||||
|
||||
// Read and clear the restartService flag
|
||||
fun consumeRestartService(): Boolean {
|
||||
val v = _restartService.value
|
||||
_restartService.value = false
|
||||
return v
|
||||
}
|
||||
/**
|
||||
* Atomically consumes the restart flag.
|
||||
* @return true if a restart was requested, false otherwise.
|
||||
*/
|
||||
fun consumeRestartService(): Boolean =
|
||||
restartService.compareAndSet(true, false)
|
||||
|
||||
// Mark reinitGroupTab as requiring tab reinitialization
|
||||
fun makeSetupGroupTab() {
|
||||
_setupGroupTab.value = true
|
||||
setupGroupTab.set(true)
|
||||
}
|
||||
|
||||
// Read and clear the reinitGroupTab flag
|
||||
fun consumeSetupGroupTab(): Boolean {
|
||||
val v = _setupGroupTab.value
|
||||
_setupGroupTab.value = false
|
||||
return v
|
||||
}
|
||||
/**
|
||||
* Atomically consumes the setup-group-tab flag.
|
||||
* @return true if UI refresh was requested, false otherwise.
|
||||
*/
|
||||
fun consumeSetupGroupTab(): Boolean =
|
||||
setupGroupTab.compareAndSet(true, false)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
@@ -20,6 +19,7 @@ import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.Language
|
||||
import com.v2ray.ang.enums.RoutingType
|
||||
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
||||
import com.v2ray.ang.extension.moveItem
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
||||
@@ -31,7 +31,6 @@ import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
@@ -205,32 +204,6 @@ object SettingsManager {
|
||||
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 = decodeSubsList()
|
||||
if (subsList.isEmpty()) return
|
||||
|
||||
Collections.swap(subsList, fromPosition, toPosition)
|
||||
MmkvManager.encodeSubsList(subsList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server via remarks.
|
||||
* @param remarks The remarks of the server.
|
||||
@@ -439,17 +412,6 @@ object SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -535,6 +497,11 @@ object SettingsManager {
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_PING_INTERVAL, AppConfig.OBSERVATORY_LEAST_PING_INTERVAL)
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_INTERVAL, AppConfig.OBSERVATORY_LEAST_LOAD_INTERVAL)
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_METHOD, AppConfig.OBSERVATORY_LEAST_LOAD_METHOD)
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_SAMPLING, AppConfig.OBSERVATORY_LEAST_LOAD_SAMPLING)
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT, AppConfig.OBSERVATORY_LEAST_LOAD_TIMEOUT)
|
||||
}
|
||||
|
||||
private fun ensureDefaultValue(key: String, default: String) {
|
||||
@@ -630,10 +597,10 @@ object SettingsManager {
|
||||
)
|
||||
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||
|
||||
// Move top
|
||||
// Move to the top
|
||||
val subsList = decodeSubsList()
|
||||
if (subsList.count() > 1) {
|
||||
swapSubscriptions(0, subsList.count() - 1)
|
||||
if (subsList.moveItem(subsList.lastIndex, 0)) {
|
||||
MmkvManager.encodeSubsList(subsList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,9 @@ import androidx.work.multiprocess.RemoteWorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.enums.NotificationChannelType
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateMessage
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.NotificationHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object SubscriptionUpdater {
|
||||
@@ -44,14 +42,15 @@ object SubscriptionUpdater {
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
}
|
||||
|
||||
MmkvManager.decodeSubscriptions().forEach { sub ->
|
||||
scheduleOne(
|
||||
context = context,
|
||||
subId = sub.guid,
|
||||
shouldRun = sub.subscription.autoUpdate,
|
||||
existingWorkPolicy = existingWorkPolicy
|
||||
)
|
||||
}
|
||||
MmkvManager.decodeSubscriptions()
|
||||
.filter { it.subscription.autoUpdate && it.subscription.url.isNotEmpty() }
|
||||
.forEach { sub ->
|
||||
scheduleOne(
|
||||
context = context,
|
||||
subId = sub.guid,
|
||||
existingWorkPolicy = existingWorkPolicy
|
||||
)
|
||||
}
|
||||
LogUtil.i(
|
||||
AppConfig.TAG,
|
||||
"SubscriptionUpdater: sync complete forceReschedule=$forceReschedule"
|
||||
@@ -63,11 +62,9 @@ object SubscriptionUpdater {
|
||||
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
|
||||
*/
|
||||
fun syncOne(context: Context = AngApplication.application, subId: String) {
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
scheduleOne(
|
||||
context = context,
|
||||
subId = subId,
|
||||
shouldRun = subItem.autoUpdate,
|
||||
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
|
||||
)
|
||||
}
|
||||
@@ -81,6 +78,17 @@ object SubscriptionUpdater {
|
||||
.cancelUniqueWork(taskName(subId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last updated timestamp and reschedule the task.
|
||||
* This is used to reset the periodic timer and prevent rapid rescheduling loops.
|
||||
*/
|
||||
fun updateLastUpdatedAndReschedule(context: Context = AngApplication.application, subId: String) {
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
subItem.lastUpdated = System.currentTimeMillis()
|
||||
MmkvManager.encodeSubscription(subId, subItem)
|
||||
syncOne(context, subId)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal scheduling logic
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -90,17 +98,20 @@ object SubscriptionUpdater {
|
||||
private fun scheduleOne(
|
||||
context: Context,
|
||||
subId: String,
|
||||
shouldRun: Boolean,
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy
|
||||
) {
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
val rw = RemoteWorkManager.getInstance(context)
|
||||
if (!shouldRun) {
|
||||
rw.cancelUniqueWork(taskName(subId))
|
||||
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for $subId")
|
||||
if (!subItem.autoUpdate) {
|
||||
cancelOne(context, subId)
|
||||
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for ${subItem.remarks}")
|
||||
return
|
||||
}
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
if (subItem.url.isEmpty()) {
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: url isEmpty for ${subItem.remarks}, skip")
|
||||
return
|
||||
}
|
||||
|
||||
val intervalMinutes = maxOf(
|
||||
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
|
||||
@@ -111,12 +122,17 @@ object SubscriptionUpdater {
|
||||
val lastUpdated = subItem.lastUpdated
|
||||
val intervalMillis = intervalMinutes * 60 * 1000L
|
||||
val now = System.currentTimeMillis()
|
||||
val initialDelayMillis = if (lastUpdated <= 0L) {
|
||||
var initialDelayMillis = if (lastUpdated <= 0L) {
|
||||
0L
|
||||
} else {
|
||||
maxOf(0L, lastUpdated + intervalMillis - now)
|
||||
}
|
||||
|
||||
// Add a small floor to initial delay to prevent rapid rescheduling loops.
|
||||
if (existingWorkPolicy == ExistingPeriodicWorkPolicy.REPLACE && initialDelayMillis < 5000L) {
|
||||
initialDelayMillis = 5000L
|
||||
}
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<UpdateTask>(intervalMinutes, TimeUnit.MINUTES)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
@@ -136,7 +152,7 @@ object SubscriptionUpdater {
|
||||
|
||||
LogUtil.i(
|
||||
AppConfig.TAG,
|
||||
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
|
||||
"SubscriptionUpdater: scheduled [${subItem.remarks}] interval=${intervalMinutes}min " +
|
||||
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
|
||||
)
|
||||
}
|
||||
@@ -153,41 +169,21 @@ object SubscriptionUpdater {
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun doWork(): Result {
|
||||
val subId = inputData.getString(KEY_SUB_ID)
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update starting: $subId")
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update starting via Service: $subId")
|
||||
|
||||
if (subId.isNullOrEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subId)
|
||||
if (subItem == null) {
|
||||
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
|
||||
return Result.success()
|
||||
}
|
||||
updateLastUpdatedAndReschedule(applicationContext, subId)
|
||||
|
||||
if (!subItem.autoUpdate) {
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: auto-update disabled for $subId, skip")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val sub = SubscriptionCache(subId, subItem)
|
||||
|
||||
// Notify about update start
|
||||
NotificationHelper.notify(
|
||||
NotificationChannelType.SUBSCRIPTION_UPDATE,
|
||||
MessageHelper.sendMsg2SubscriptionService(
|
||||
applicationContext,
|
||||
applicationContext.getString(R.string.title_pref_auto_update_subscription),
|
||||
"Updating ${sub.subscription.remarks}"
|
||||
SubscriptionUpdateMessage(AppConfig.MSG_SUB_UPDATE_START, true, listOf(subId))
|
||||
)
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
|
||||
AngConfigManager.updateConfigViaSub(sub)
|
||||
|
||||
// Clear notification
|
||||
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toast
|
||||
@@ -15,14 +16,14 @@ import com.v2ray.ang.util.LogUtil
|
||||
* Helper for choosing and creating files using Android Storage Access Framework.
|
||||
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
|
||||
*/
|
||||
class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
class FileChooserHelper(private val activity: ComponentActivity) {
|
||||
private var fileChooserCallback: ((Uri?) -> Unit)? = null
|
||||
private var documentCreateCallback: ((Uri?) -> Unit)? = null
|
||||
|
||||
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
|
||||
if (result.resultCode == Activity.RESULT_OK && uri != null) {
|
||||
fileChooserCallback?.invoke(uri)
|
||||
} else {
|
||||
fileChooserCallback?.invoke(null)
|
||||
@@ -85,4 +86,4 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
documentCreateCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +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
|
||||
|
||||
/**
|
||||
* Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
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.<br></br>
|
||||
* <br></br>
|
||||
* 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
|
||||
* @see RecyclerView.ViewHolder.getAdapterPosition
|
||||
*/
|
||||
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
|
||||
|
||||
|
||||
fun onItemMoveCompleted()
|
||||
|
||||
/**
|
||||
* Called when an item has been dismissed by a swipe.<br></br>
|
||||
* <br></br>
|
||||
* 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
|
||||
* @see RecyclerView.ViewHolder.getAdapterPosition
|
||||
*/
|
||||
fun onItemDismiss(position: Int)
|
||||
}
|
||||
@@ -1,38 +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.ItemTouchHelper
|
||||
|
||||
/**
|
||||
* Interface to notify an item ViewHolder of relevant callbacks from [ ].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
interface ItemTouchHelperViewHolder {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun onItemSelected()
|
||||
|
||||
|
||||
/**
|
||||
* Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
|
||||
* state should be cleared.
|
||||
*/
|
||||
fun onItemClear()
|
||||
}
|
||||
+39
-3
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.util
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
@@ -6,11 +6,14 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateMessage
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.service.CoreTestService
|
||||
import com.v2ray.ang.service.SubscriptionUpdateService
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import java.io.Serializable
|
||||
|
||||
object MessageUtil {
|
||||
object MessageHelper {
|
||||
|
||||
|
||||
/**
|
||||
@@ -69,6 +72,39 @@ object MessageUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the subscription service.
|
||||
*
|
||||
* @param ctx The context.
|
||||
* @param message The subscription service message containing key and subId.
|
||||
*/
|
||||
fun sendMsg2SubscriptionService(ctx: Context, message: SubscriptionUpdateMessage) {
|
||||
try {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(ctx, SubscriptionUpdateService::class.java)
|
||||
intent.putExtra("content", message)
|
||||
when (message.key) {
|
||||
AppConfig.MSG_SUB_UPDATE_START -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ContextCompat.startForegroundService(ctx, intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_SUB_UPDATE_CANCEL -> {
|
||||
ctx.stopService(intent)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message to subscription service", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message with the specified action.
|
||||
*
|
||||
@@ -89,4 +125,4 @@ object MessageUtil {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
/**
|
||||
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
||||
* This ensures that all Preference UI operations read/write directly from/to MMKV,
|
||||
* avoiding inconsistencies between SharedPreferences and MMKV.
|
||||
*/
|
||||
class MmkvPreferenceDataStore : PreferenceDataStore() {
|
||||
|
||||
override fun putString(key: String, value: String?) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getString(key: String, defaultValue: String?): String? {
|
||||
return MmkvManager.decodeSettingsString(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putInt(key: String, value: Int) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Int {
|
||||
return MmkvManager.decodeSettingsInt(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putLong(key: String, value: Long) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Long {
|
||||
return MmkvManager.decodeSettingsLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putFloat(key: String, value: Float) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Float {
|
||||
return MmkvManager.decodeSettingsFloat(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String, values: MutableSet<String>?) {
|
||||
if (values == null) {
|
||||
MmkvManager.encodeSettings(key, null as String?)
|
||||
} else {
|
||||
MmkvManager.encodeSettings(key, values)
|
||||
}
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValues: MutableSet<String>?): MutableSet<String>? {
|
||||
return MmkvManager.decodeSettingsStringSet(key) ?: defaultValues
|
||||
}
|
||||
|
||||
// Internal helper: notify other modules about setting changes
|
||||
private fun notifySettingChanged(key: String) {
|
||||
if (key == AppConfig.PREF_LOGLEVEL) {
|
||||
LogUtil.refreshLogLevel()
|
||||
}
|
||||
|
||||
// Call SettingsManager.setNightMode if UI mode changed
|
||||
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
|
||||
SettingsManager.setNightMode()
|
||||
}
|
||||
// Notify listeners that require service restart or reinit
|
||||
SettingsChangeManager.makeRestartService()
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.util
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -55,13 +55,14 @@ object NotificationHelper {
|
||||
fun updateNotification(
|
||||
channelType: NotificationChannelType,
|
||||
context: Context,
|
||||
title: String,
|
||||
content: String
|
||||
) {
|
||||
val notificationManager = getNotificationManager(context)
|
||||
|
||||
// Get or create builder from cache
|
||||
val builder = builderCache.getOrPut(channelType.notificationId) {
|
||||
buildNotificationBuilder(channelType, context, "", content)
|
||||
buildNotificationBuilder(channelType, context, title, content)
|
||||
}
|
||||
|
||||
// Update only the content text (fast operation)
|
||||
@@ -158,5 +159,4 @@ object NotificationHelper {
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.enums.PermissionType
|
||||
@@ -12,7 +12,7 @@ import com.v2ray.ang.extension.toast
|
||||
/**
|
||||
* Helper for requesting permissions.
|
||||
*/
|
||||
class PermissionHelper(private val activity: AppCompatActivity) {
|
||||
class PermissionHelper(private val activity: ComponentActivity) {
|
||||
private var permissionCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
private val permissionLauncher: ActivityResultLauncher<String> =
|
||||
@@ -43,4 +43,4 @@ class PermissionHelper(private val activity: AppCompatActivity) {
|
||||
permissionLauncher.launch(permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.v2ray.ang.ui.ScannerActivity
|
||||
|
||||
/**
|
||||
@@ -12,12 +13,12 @@ import com.v2ray.ang.ui.ScannerActivity
|
||||
* This class encapsulates the logic for launching the QR code scanner activity
|
||||
* and handling the scan result.
|
||||
*/
|
||||
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
|
||||
class QRCodeScannerHelper(private val activity: ComponentActivity) {
|
||||
private var scanCallback: ((String?) -> Unit)? = null
|
||||
|
||||
private val scanLauncher: ActivityResultLauncher<Intent> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
|
||||
scanCallback?.invoke(scanResult)
|
||||
} else {
|
||||
|
||||
@@ -1,147 +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.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.<br></br>
|
||||
*
|
||||
* 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, private val allowSwipe: Boolean = false) : ItemTouchHelper.Callback() {
|
||||
private var mReturnAnimator: ValueAnimator? = null
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = true
|
||||
|
||||
override fun isItemViewSwipeEnabled(): Boolean = allowSwipe
|
||||
|
||||
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 = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
|
||||
} else {
|
||||
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@ package com.v2ray.ang.receiver
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.UserManager
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.core.LauncherManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -12,18 +13,36 @@ import com.v2ray.ang.util.LogUtil
|
||||
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.
|
||||
* It handles BOOT_COMPLETED, LOCKED_BOOT_COMPLETED, and MY_PACKAGE_REPLACED.
|
||||
* 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?) {
|
||||
LogUtil.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
|
||||
val action = intent?.action ?: return
|
||||
if (context == null) return
|
||||
|
||||
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
|
||||
LogUtil.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
|
||||
return
|
||||
LogUtil.i(AppConfig.TAG, "BootReceiver received: $action")
|
||||
|
||||
when (action) {
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
||||
// Continue
|
||||
}
|
||||
|
||||
Intent.ACTION_LOCKED_BOOT_COMPLETED -> {
|
||||
val userManager = context.getSystemService(Context.USER_SERVICE) as? UserManager
|
||||
if (userManager != null && !userManager.isUserUnlocked) {
|
||||
LogUtil.w(AppConfig.TAG, "BootReceiver: User is locked, skipping auto start")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LogUtil.w(AppConfig.TAG, "BootReceiver: Unhandled action: $action")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!MmkvManager.decodeStartOnBoot()) {
|
||||
@@ -37,7 +56,7 @@ class BootReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
|
||||
CoreServiceManager.startVService(context)
|
||||
LauncherManager.startService(context)
|
||||
SubscriptionUpdater.sync(context)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.core.LauncherManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
class TaskerReceiver : BroadcastReceiver() {
|
||||
@@ -28,12 +28,12 @@ class TaskerReceiver : BroadcastReceiver() {
|
||||
return
|
||||
} else if (switch) {
|
||||
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
|
||||
CoreServiceManager.startVServiceFromToggle(context)
|
||||
LauncherManager.startServiceFromToggle(context)
|
||||
} else {
|
||||
CoreServiceManager.startVService(context, guid)
|
||||
LauncherManager.startService(context, guid)
|
||||
}
|
||||
} else {
|
||||
CoreServiceManager.stopVService(context)
|
||||
LauncherManager.stopService(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.widget.RemoteViews
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.core.LauncherManager
|
||||
|
||||
class WidgetProvider : AppWidgetProvider() {
|
||||
/**
|
||||
@@ -68,9 +69,9 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
super.onReceive(context, intent)
|
||||
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.stopVService(context)
|
||||
LauncherManager.stopService(context)
|
||||
} else {
|
||||
CoreServiceManager.startVServiceFromToggle(context)
|
||||
LauncherManager.startServiceFromToggle(context)
|
||||
}
|
||||
} else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
|
||||
AppWidgetManager.getInstance(context)?.let { manager ->
|
||||
|
||||
@@ -109,6 +109,8 @@ object RootProxyManager {
|
||||
return null
|
||||
}
|
||||
val appUid = context.applicationInfo.uid
|
||||
val socksUsername = SettingsManager.getSocksUsername()
|
||||
val socksPassword = SettingsManager.getSocksPassword()
|
||||
val port = SettingsManager.getSocksPort()
|
||||
val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
|
||||
val pidFile = File(runDir, "tun2socks.pid").absolutePath
|
||||
@@ -145,7 +147,7 @@ object RootProxyManager {
|
||||
// (loopback, already RETURNed by the 127.0.0.0/8 bypass) and the core's real outbound
|
||||
// runs as the app uid (RETURNed by the uid-owner rule), so traffic can't loop.
|
||||
appendLine("cat > '$cfgFile' <<'HEVCFG'")
|
||||
append(buildHevConfig(port, ipv6))
|
||||
append(buildHevConfig(socksUsername = socksUsername, socksPassword = socksPassword, socksPort = port, ipv6 = ipv6))
|
||||
appendLine("HEVCFG")
|
||||
appendLine("nohup \"\$BIN\" '$cfgFile' >'$logFile' 2>&1 &")
|
||||
appendLine("T2S_PID=\$!")
|
||||
@@ -195,7 +197,7 @@ object RootProxyManager {
|
||||
* tun address when IPv6 is enabled; whether v6 actually flows in is decided separately by
|
||||
* the v6 route into [TABLE].
|
||||
*/
|
||||
private fun buildHevConfig(socksPort: Int, ipv6: Boolean): String {
|
||||
private fun buildHevConfig(socksUsername: String?, socksPassword: String?, socksPort: Int, ipv6: Boolean): String {
|
||||
val v4 = AppConfig.ROOT_TUN_ADDR_V4.substringBefore("/")
|
||||
val v6 = AppConfig.ROOT_TUN_ADDR_V6.substringBefore("/")
|
||||
return buildString {
|
||||
@@ -209,6 +211,10 @@ object RootProxyManager {
|
||||
appendLine(" port: $socksPort")
|
||||
appendLine(" address: '${AppConfig.LOOPBACK}'")
|
||||
appendLine(" udp: 'udp'")
|
||||
if (socksUsername != null && socksPassword != null) {
|
||||
appendLine(" username: '$socksUsername'")
|
||||
appendLine(" password: '$socksPassword'")
|
||||
}
|
||||
appendLine(" tcp-fastopen: true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,11 @@ object RootShell {
|
||||
writeText(script)
|
||||
setExecutable(true, false)
|
||||
}
|
||||
return exec("sh ${file.absolutePath}")
|
||||
val safePath = file.absolutePath.replace("'", "'\\''")
|
||||
return exec("sh '$safePath'")
|
||||
}
|
||||
|
||||
fun exec(command: String, timeoutSeconds: Long = 30): Result {
|
||||
private fun exec(command: String, timeoutSeconds: Long = 30): Result {
|
||||
return try {
|
||||
val process = ProcessBuilder("su", "-c", command)
|
||||
.redirectErrorStream(true)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
@@ -30,8 +31,15 @@ class CoreProxyOnlyService : Service(), ServiceControl {
|
||||
* @return The start mode.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
NotificationManager.ensureForeground()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
|
||||
CoreServiceManager.startCoreLoop(null)
|
||||
|
||||
if (!CoreServiceManager.startCoreLoop(null)) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Proxy: Failed to start core loop")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.root.RootProxyManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -39,6 +40,7 @@ class CoreRootService : Service(), ServiceControl {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
NotificationManager.ensureForeground()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Root: command received")
|
||||
|
||||
// Start the in-process core first (this also posts the foreground notification),
|
||||
|
||||
@@ -11,9 +11,9 @@ import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.enums.NotificationChannelType
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.helper.NotificationHelper
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.NotificationHelper
|
||||
import java.util.Collections
|
||||
|
||||
class CoreTestService : Service() {
|
||||
@@ -59,6 +59,12 @@ class CoreTestService : Service() {
|
||||
* @return The start mode.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
NotificationHelper.startForeground(
|
||||
this,
|
||||
NotificationChannelType.CORE_TEST,
|
||||
getString(R.string.app_name),
|
||||
getString(R.string.title_real_ping_all_server)
|
||||
)
|
||||
val message = intent?.serializable<TestServiceMessage>("content")
|
||||
if (message == null) {
|
||||
stopSelf(startId)
|
||||
@@ -78,13 +84,6 @@ class CoreTestService : Service() {
|
||||
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
|
||||
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
|
||||
|
||||
NotificationHelper.startForeground(
|
||||
this,
|
||||
NotificationChannelType.CORE_TEST,
|
||||
getString(R.string.app_name),
|
||||
getString(R.string.title_real_ping_all_server)
|
||||
)
|
||||
|
||||
val guidsList = when {
|
||||
message.serverGuids.isNotEmpty() -> message.serverGuids
|
||||
message.subscriptionId.isNotEmpty() -> MmkvManager.decodeServerList(message.subscriptionId)
|
||||
@@ -96,6 +95,7 @@ class CoreTestService : Service() {
|
||||
worker = RealPingWorkerService(
|
||||
context = this,
|
||||
guids = guidsList,
|
||||
onlyTcp = message.onlyTcp,
|
||||
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
|
||||
)
|
||||
activeWorkers.add(worker)
|
||||
@@ -112,18 +112,19 @@ class CoreTestService : Service() {
|
||||
NotificationHelper.updateNotification(
|
||||
channelType = NotificationChannelType.CORE_TEST,
|
||||
context = this,
|
||||
title = getString(R.string.app_name),
|
||||
content = getString(R.string.connection_runing_task_left, event.text)
|
||||
)
|
||||
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
|
||||
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
|
||||
}
|
||||
|
||||
is RealPingEvent.Result -> {
|
||||
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
|
||||
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
|
||||
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
|
||||
}
|
||||
|
||||
is RealPingEvent.Finish -> {
|
||||
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
|
||||
MessageHelper.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
|
||||
onWorkerDone()
|
||||
if (activeWorkers.isEmpty()) {
|
||||
NotificationHelper.stopForeground(this)
|
||||
@@ -141,4 +142,4 @@ class CoreTestService : Service() {
|
||||
NotificationHelper.stopForeground(this)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,12 @@ import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
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 androidx.annotation.RequiresApi
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.BuildConfig
|
||||
@@ -29,49 +25,14 @@ import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.lang.ref.SoftReference
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@SuppressLint("VpnServicePolicy")
|
||||
class CoreVpnService : VpnService(), ServiceControl {
|
||||
private lateinit var mInterface: ParcelFileDescriptor
|
||||
private var isRunning = false
|
||||
private var tun2SocksService: Tun2SocksControl? = null
|
||||
|
||||
/**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)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val isStartingLock = AtomicBoolean(false)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -109,16 +70,31 @@ class CoreVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
}
|
||||
|
||||
unlockStart()
|
||||
NotificationManager.cancelNotification()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
|
||||
NotificationManager.showNotification(null)
|
||||
setupVpnService()
|
||||
NotificationManager.ensureForeground()
|
||||
// Always-on VPN restarts from OS deliver intent.action == SERVICE_INTERFACE or null intent.
|
||||
// Reset any stuck start lock left by a killed process to allow setupVpnService() to run.
|
||||
val isSystemVpnStart = intent == null || intent.action == SERVICE_INTERFACE
|
||||
if (isSystemVpnStart) {
|
||||
unlockStart()
|
||||
}
|
||||
if (!tryLockStart()) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Start already in progress")
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received, systemVpnStart=$isSystemVpnStart")
|
||||
if (!setupVpnService()) {
|
||||
unlockStart()
|
||||
// Stop service if setup fails to avoid infinite restart loops (START_STICKY)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startService()
|
||||
return START_STICKY
|
||||
//return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun getService(): Service {
|
||||
@@ -148,6 +124,10 @@ class CoreVpnService : VpnService(), ServiceControl {
|
||||
return protect(socket)
|
||||
}
|
||||
|
||||
override fun setUnderlyingNetworks(networks: Array<Network>?): Boolean {
|
||||
return super<VpnService>.setUnderlyingNetworks(networks)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
val context = newBase?.let {
|
||||
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
|
||||
@@ -159,21 +139,20 @@ class CoreVpnService : VpnService(), ServiceControl {
|
||||
* Sets up the VPN service.
|
||||
* Prepares the VPN and configures it if preparation is successful.
|
||||
*/
|
||||
private fun setupVpnService() {
|
||||
private fun setupVpnService(): Boolean {
|
||||
val prepare = prepare(this)
|
||||
if (prepare != null) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
|
||||
stopSelf()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (configureVpnService() != true) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
|
||||
stopSelf()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
runTun2socks()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,15 +246,6 @@ class CoreVpnService : VpnService(), ServiceControl {
|
||||
* @param builder The VPN Builder to configure
|
||||
*/
|
||||
private fun configurePlatformFeatures(builder: Builder) {
|
||||
// Android P (API 28) and above: Configure network callbacks
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
try {
|
||||
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Android Q (API 29) and above: Configure metering and HTTP proxy
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
builder.setMetered(false)
|
||||
@@ -354,14 +324,8 @@ class CoreVpnService : VpnService(), ServiceControl {
|
||||
// val emptyInfo = VpnNetworkInfo()
|
||||
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
|
||||
// saveVpnNetworkInfo(configName, info)
|
||||
unlockStart()
|
||||
isRunning = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
try {
|
||||
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
tun2SocksService?.stopTun2Socks()
|
||||
tun2SocksService = null
|
||||
@@ -397,5 +361,14 @@ class CoreVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tryLockStart(): Boolean {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-VPN: tryLockStart: ${isStartingLock.get()}")
|
||||
return isStartingLock.compareAndSet(false, true)
|
||||
}
|
||||
|
||||
fun unlockStart() {
|
||||
isStartingLock.set(false)
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-VPN: unlockStart")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.IDialerService
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Looper
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import com.v2ray.ang.contracts.IDialerService
|
||||
|
||||
class DialerWebviewService : IDialerService {
|
||||
private var webView: WebView? = null
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Watches the network that carries the tunnel and reports topology changes.
|
||||
*
|
||||
* Cellular -> Wi-Fi is a make-before-break handover: the new network is announced while the old one
|
||||
* is still connected, so the socket to the server is never reset and the core keeps using a dead
|
||||
* connection. Deciding that a handover happened is what this class is for, acting on it is not.
|
||||
*
|
||||
* Only used from Android P and above, see CoreServiceManager.startNetworkMonitor().
|
||||
* [onHandover] is invoked on a background thread after the debounce window and may block.
|
||||
*/
|
||||
class NetworkMonitor(
|
||||
private val connectivity: ConnectivityManager,
|
||||
private val onUnderlyingNetworksChanged: (Array<Network>?) -> Unit,
|
||||
private val onHandover: () -> Unit,
|
||||
) {
|
||||
private companion object {
|
||||
const val HANDOVER_DEBOUNCE_MS = 1000L
|
||||
}
|
||||
|
||||
private var upstream: Network? = null
|
||||
private var handoverJob: Job? = null
|
||||
private var registered = false
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private val request by lazy {
|
||||
NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
val previous = upstream
|
||||
upstream = network
|
||||
onUnderlyingNetworksChanged(arrayOf(network))
|
||||
if (previous != null && previous != network) {
|
||||
scheduleHandover(network)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||
// it's a good idea to refresh capabilities
|
||||
onUnderlyingNetworksChanged(arrayOf(network))
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
onUnderlyingNetworksChanged(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts watching. Safe to call more than once, only the first call registers.
|
||||
*/
|
||||
fun register() {
|
||||
if (registered) return
|
||||
try {
|
||||
connectivity.requestNetwork(request, callback)
|
||||
registered = true
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "NetworkMonitor: Failed to request network", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops watching and drops the tracked state. Safe to call more than once.
|
||||
*/
|
||||
fun unregister() {
|
||||
handoverJob?.cancel()
|
||||
handoverJob = null
|
||||
upstream = null
|
||||
if (!registered) return
|
||||
registered = false
|
||||
try {
|
||||
connectivity.unregisterNetworkCallback(callback)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.w(AppConfig.TAG, "NetworkMonitor: Failed to unregister callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleHandover(network: Network) {
|
||||
LogUtil.i(AppConfig.TAG, "NetworkMonitor: Upstream is now $network")
|
||||
handoverJob?.cancel()
|
||||
handoverJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
delay(HANDOVER_DEBOUNCE_MS)
|
||||
onHandover()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "NetworkMonitor: Failed to handle upstream change", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.core.LauncherManager
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
@@ -50,7 +51,7 @@ class QSTileService : TileService() {
|
||||
mMsgReceive = ReceiveMessageHandler(this)
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY)
|
||||
ContextCompat.registerReceiver(applicationContext, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
|
||||
MessageHelper.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,11 +76,11 @@ class QSTileService : TileService() {
|
||||
super.onClick()
|
||||
when (qsTile.state) {
|
||||
Tile.STATE_INACTIVE -> {
|
||||
CoreServiceManager.startVServiceFromToggle(this)
|
||||
LauncherManager.startServiceFromToggle(this)
|
||||
}
|
||||
|
||||
Tile.STATE_ACTIVE -> {
|
||||
CoreServiceManager.stopVService(this)
|
||||
LauncherManager.stopService(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
class RealPingWorkerService(
|
||||
private val context: Context,
|
||||
private val guids: List<String>,
|
||||
private val onlyTcp: Boolean = false,
|
||||
private val onEvent: (RealPingEvent) -> Unit = {}
|
||||
) {
|
||||
private val job = SupervisorJob()
|
||||
private val concurrency = SettingsManager.getRealPingConcurrency()
|
||||
private val dispatcher = Executors.newFixedThreadPool(concurrency).asCoroutineDispatcher()
|
||||
private val dispatcher = Executors.newFixedThreadPool(if (onlyTcp) concurrency * 2 else concurrency).asCoroutineDispatcher()
|
||||
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
|
||||
|
||||
private val runningCount = AtomicInteger(0)
|
||||
@@ -43,7 +44,7 @@ class RealPingWorkerService(
|
||||
scope.launch {
|
||||
runningCount.incrementAndGet()
|
||||
try {
|
||||
val result = startRealPing(guid)
|
||||
val result = if (onlyTcp) startTcping(guid) else startRealPing(guid)
|
||||
onEvent(RealPingEvent.Result(guid, result))
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
@@ -85,6 +86,8 @@ class RealPingWorkerService(
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
|
||||
if (!config.configType.isComplexType()
|
||||
&& config.configType != EConfigType.HYSTERIA2
|
||||
&& config.configType != EConfigType.WIREGUARD
|
||||
&& config.alpn?.startsWith("h3") != true
|
||||
&& config.server.isNotNullEmpty()
|
||||
&& config.serverPort?.toIntOrNull() != null
|
||||
) {
|
||||
@@ -102,4 +105,25 @@ class RealPingWorkerService(
|
||||
}
|
||||
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
}
|
||||
|
||||
private fun startTcping(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
|
||||
if (!config.configType.isComplexType()
|
||||
&& config.configType != EConfigType.HYSTERIA2
|
||||
&& config.configType != EConfigType.WIREGUARD
|
||||
&& config.alpn?.startsWith("h3") != true
|
||||
&& config.server.isNotNullEmpty()
|
||||
&& config.serverPort?.toIntOrNull() != null
|
||||
) {
|
||||
val url = config.server.orEmpty()
|
||||
val port = config.serverPort.orEmpty().toInt()
|
||||
val tcpTime = SpeedtestManager.socketConnectTime(url, port, 1000)
|
||||
|
||||
return tcpTime
|
||||
}
|
||||
|
||||
return retFailure
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.dto.RealPingEvent
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateMessage
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.enums.NotificationChannelType
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.NotificationHelper
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class SubscriptionUpdateService : Service() {
|
||||
|
||||
private val serviceJob = Job()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
|
||||
|
||||
private val runningTasks = AtomicInteger(0)
|
||||
|
||||
// manage active batch workers so each batch is independent and cancellable
|
||||
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
|
||||
|
||||
private val updateSemaphore = Semaphore(2)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CoreNativeManager.initCoreEnv(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService is being destroyed")
|
||||
val snapshot = ArrayList(activeWorkers)
|
||||
snapshot.forEach { it.cancel() }
|
||||
activeWorkers.clear()
|
||||
serviceJob.cancel()
|
||||
NotificationHelper.stopForeground(this)
|
||||
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
NotificationHelper.startForeground(
|
||||
this,
|
||||
NotificationChannelType.SUBSCRIPTION_UPDATE,
|
||||
getString(R.string.title_pref_auto_update_subscription),
|
||||
getString(R.string.app_name)
|
||||
)
|
||||
val message = intent?.serializable<SubscriptionUpdateMessage>("content")
|
||||
if (message == null) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
when (message.key) {
|
||||
AppConfig.MSG_SUB_UPDATE_START -> handleUpdateStart(message)
|
||||
AppConfig.MSG_SUB_UPDATE_CANCEL -> {
|
||||
NotificationHelper.stopForeground(this)
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
else -> {
|
||||
NotificationHelper.stopForeground(this)
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun handleUpdateStart(message: SubscriptionUpdateMessage) {
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService starting update task for ${message.subIds.size} subscriptions")
|
||||
|
||||
runningTasks.incrementAndGet()
|
||||
serviceScope.launch {
|
||||
updateSemaphore.withPermit {
|
||||
try {
|
||||
message.subIds.forEach { subId ->
|
||||
updateSingle(subId, message.forcedUpdate)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "SubscriptionUpdateService update failed", e)
|
||||
} finally {
|
||||
if (runningTasks.decrementAndGet() == 0 && activeWorkers.isEmpty()) {
|
||||
NotificationHelper.stopForeground(this@SubscriptionUpdateService)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateSingle(subId: String, forcedUpdate: Boolean) {
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
if (!subItem.enabled || subItem.url.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val sub = SubscriptionCache(subId, subItem)
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Updating ${subItem.remarks}")
|
||||
showNotification(
|
||||
context = this,
|
||||
titleResId = R.string.title_pref_auto_update_subscription,
|
||||
content = "Updating ${subItem.remarks}"
|
||||
)
|
||||
|
||||
if (forcedUpdate || MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)) {
|
||||
AngConfigManager.updateConfigViaSub(sub)
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)) {
|
||||
testSubscriptionServers(sub)
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: removing invalid servers for ${subItem.remarks}")
|
||||
showNotification(
|
||||
context = this,
|
||||
titleResId = R.string.title_del_invalid_config,
|
||||
content = subItem.remarks
|
||||
)
|
||||
AngConfigManager.removeInvalidServer(subId)
|
||||
}
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: sorting servers for ${subItem.remarks}")
|
||||
showNotification(
|
||||
context = this,
|
||||
titleResId = R.string.title_sort_by_test_results,
|
||||
content = subItem.remarks
|
||||
)
|
||||
AngConfigManager.sortByTestResultsForSub(subId)
|
||||
}
|
||||
}
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Finished ${subItem.remarks}")
|
||||
}
|
||||
|
||||
private suspend fun testSubscriptionServers(sub: SubscriptionCache) {
|
||||
val subId = sub.guid
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: starting test phase for ${sub.subscription.remarks}")
|
||||
showNotification(
|
||||
context = this,
|
||||
titleResId = R.string.title_real_ping_all_server,
|
||||
content = sub.subscription.remarks
|
||||
)
|
||||
|
||||
val guids = MmkvManager.decodeServerList(subId)
|
||||
if (guids.isNotEmpty()) {
|
||||
val deferred = CompletableDeferred<Unit>()
|
||||
lateinit var worker: RealPingWorkerService
|
||||
worker = RealPingWorkerService(
|
||||
context = this,
|
||||
guids = guids,
|
||||
onEvent = { event ->
|
||||
handleWorkerEvent(event, sub.subscription.remarks) {
|
||||
activeWorkers.remove(worker)
|
||||
deferred.complete(Unit)
|
||||
}
|
||||
}
|
||||
)
|
||||
activeWorkers.add(worker)
|
||||
worker.start()
|
||||
deferred.await()
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: test phase finished for ${sub.subscription.remarks}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWorkerEvent(event: RealPingEvent, remarks: String, onWorkerDone: () -> Unit) {
|
||||
when (event) {
|
||||
is RealPingEvent.Progress -> {
|
||||
val text = "${event.text} in $remarks"
|
||||
showNotification(
|
||||
context = this,
|
||||
titleResId = R.string.title_real_ping_all_server,
|
||||
content = text
|
||||
)
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: $text")
|
||||
}
|
||||
|
||||
is RealPingEvent.Result -> {
|
||||
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
|
||||
}
|
||||
|
||||
is RealPingEvent.Finish -> {
|
||||
onWorkerDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNotification(context: Context, titleResId: Int, content: String) {
|
||||
NotificationHelper.notify(
|
||||
NotificationChannelType.SUBSCRIPTION_UPDATE,
|
||||
context,
|
||||
context.getString(titleResId),
|
||||
content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,15 @@ class TProxyService(
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyStartService(configPath: String, fd: Int)
|
||||
private external fun TProxyStartService(configPath: String, fd: Int): Boolean
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyStopService()
|
||||
private external fun TProxyStopService(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyIsRunning(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
|
||||
@@ -1,52 +1,131 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.databinding.ActivityAboutBinding
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.VersionInfoBlock
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class AboutActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
|
||||
class AboutActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_about))
|
||||
}
|
||||
|
||||
binding.layoutSoureCcode.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.APP_URL)
|
||||
}
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
AboutScreen(onBackClick = { finish() })
|
||||
}
|
||||
}
|
||||
|
||||
binding.layoutFeedback.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.APP_ISSUES_URL)
|
||||
}
|
||||
@Composable
|
||||
fun AboutScreen(onBackClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var showOssDialog by remember { mutableStateOf(false) }
|
||||
|
||||
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()
|
||||
}
|
||||
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
|
||||
val appIdText = BuildConfig.APPLICATION_ID
|
||||
|
||||
binding.layoutTgChannel.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.TG_CHANNEL_URL)
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_about),
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
}
|
||||
|
||||
binding.layoutPrivacyPolicy.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
|
||||
}
|
||||
|
||||
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
|
||||
binding.tvVersion.text = it
|
||||
}
|
||||
BuildConfig.APPLICATION_ID.also {
|
||||
binding.tvAppId.text = it
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_source_code_24dp),
|
||||
title = stringResource(R.string.title_source_code),
|
||||
onClick = { Utils.openUri(context, AppConfig.APP_URL) }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.license_24px),
|
||||
title = stringResource(R.string.title_oss_license),
|
||||
onClick = { showOssDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_feedback_24dp),
|
||||
title = stringResource(R.string.title_pref_feedback),
|
||||
onClick = { Utils.openUri(context, AppConfig.APP_ISSUES_URL) }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_telegram_24dp),
|
||||
title = stringResource(R.string.title_tg_channel),
|
||||
onClick = { Utils.openUri(context, AppConfig.TG_CHANNEL_URL) }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_privacy_24dp),
|
||||
title = stringResource(R.string.title_privacy_policy),
|
||||
onClick = { Utils.openUri(context, AppConfig.APP_PRIVACY_POLICY) }
|
||||
)
|
||||
VersionInfoBlock(
|
||||
versionText = versionText,
|
||||
appIdText = appIdText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showOssDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showOssDialog = false },
|
||||
title = { Text(stringResource(R.string.title_oss_license)) },
|
||||
text = {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
loadUrl("file:///android_asset/open_source_licenses.html")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 300.dp)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showOssDialog = false }) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.padding(bottom = 60.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityAppPickerBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.Collator
|
||||
|
||||
class AppPickerActivity : BaseActivity() {
|
||||
companion object {
|
||||
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
|
||||
private const val EXTRA_PICKER_TITLE = "picker_title"
|
||||
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
selectedPackages: Collection<String> = emptyList(),
|
||||
title: String? = null
|
||||
): Intent = Intent(context, AppPickerActivity::class.java).apply {
|
||||
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(selectedPackages))
|
||||
title?.let { putExtra(EXTRA_PICKER_TITLE, it) }
|
||||
}
|
||||
|
||||
fun getSelectedPackages(intent: Intent?): List<String> {
|
||||
return intent?.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
|
||||
private val initialSelectedPackages by lazy {
|
||||
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||
}
|
||||
private val selectedPackages = LinkedHashSet<String>()
|
||||
private var appsAll: List<AppInfo> = emptyList()
|
||||
private val adapter = AppSelectorAdapter(selectedPackages)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
|
||||
|
||||
selectedPackages.addAll(initialSelectedPackages)
|
||||
setupRecyclerView()
|
||||
loadApps()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_app_picker, menu)
|
||||
|
||||
val searchItem = menu.findItem(R.id.search_view)
|
||||
if (searchItem != null) {
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
filterApps(newText.orEmpty())
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.select_all -> {
|
||||
selectAllVisible()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.invert_selection -> {
|
||||
invertVisibleSelection()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
setResult(
|
||||
RESULT_OK,
|
||||
Intent().apply {
|
||||
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
|
||||
}
|
||||
)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
binding.recyclerView.adapter = adapter
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
}
|
||||
|
||||
@SuppressLint("UseCompatLoadingForDrawables")
|
||||
private fun createSpecialItemUnidentified(): AppInfo {
|
||||
val icon = requireNotNull(
|
||||
getDrawable(android.R.drawable.ic_menu_help)
|
||||
?: getDrawable(android.R.drawable.sym_def_app_icon)
|
||||
) { "No fallback drawable available" }
|
||||
return AppInfo(
|
||||
appName = getString(R.string.app_picker_unknown_app),
|
||||
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
|
||||
appIcon = icon,
|
||||
isSystemApp = false,
|
||||
isSelected = 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
|
||||
val sortedApps = sortApps(appsList)
|
||||
listOf(createSpecialItemUnidentified()) + sortedApps
|
||||
}
|
||||
|
||||
appsAll = apps
|
||||
updateDisplayedApps(apps)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterApps(content: String) {
|
||||
val key = content.uppercase()
|
||||
val filteredApps = appsAll.filter { app ->
|
||||
key.isEmpty() || matchesSearch(app, key)
|
||||
}
|
||||
updateDisplayedApps(filteredApps)
|
||||
}
|
||||
|
||||
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
|
||||
val collator = Collator.getInstance()
|
||||
return apps.sortedWith { p1, p2 ->
|
||||
val p1Selected = selectedPackages.contains(p1.packageName)
|
||||
val p2Selected = selectedPackages.contains(p2.packageName)
|
||||
when {
|
||||
p1Selected && !p2Selected -> -1
|
||||
!p1Selected && p2Selected -> 1
|
||||
p1.isSystemApp && !p2.isSystemApp -> 1
|
||||
!p1.isSystemApp && p2.isSystemApp -> -1
|
||||
else -> collator.compare(p1.appName, p2.appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
|
||||
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
|
||||
}
|
||||
|
||||
private fun updateDisplayedApps(apps: List<AppInfo>) {
|
||||
adapter.submitList(apps)
|
||||
}
|
||||
|
||||
private fun selectAllVisible() {
|
||||
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
|
||||
adapter.refreshSelection()
|
||||
}
|
||||
|
||||
private fun invertVisibleSelection() {
|
||||
adapter.apps.forEach { app ->
|
||||
if (selectedPackages.contains(app.packageName)) {
|
||||
selectedPackages.remove(app.packageName)
|
||||
} else {
|
||||
selectedPackages.add(app.packageName)
|
||||
}
|
||||
}
|
||||
adapter.refreshSelection()
|
||||
}
|
||||
|
||||
private fun getSelectedPackages(): ArrayList<String> {
|
||||
return ArrayList(selectedPackages.sorted())
|
||||
}
|
||||
|
||||
private fun resolveScreenTitle(): String {
|
||||
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
|
||||
class AppSelectorAdapter(
|
||||
private val selectedPackages: MutableSet<String>
|
||||
) : RecyclerView.Adapter<AppSelectorAdapter.AppViewHolder>() {
|
||||
|
||||
var apps: List<AppInfo> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
|
||||
val binding = ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return AppViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = apps.size
|
||||
|
||||
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
|
||||
holder.bind(apps[position])
|
||||
}
|
||||
|
||||
fun submitList(newApps: List<AppInfo>) {
|
||||
apps = newApps
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun refreshSelection() {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
inner class AppViewHolder(private val binding: ItemRecyclerBypassListBinding) : RecyclerView.ViewHolder(binding.root),
|
||||
View.OnClickListener {
|
||||
private lateinit var appInfo: AppInfo
|
||||
|
||||
fun bind(item: AppInfo) {
|
||||
appInfo = item
|
||||
binding.icon.setImageDrawable(item.appIcon)
|
||||
binding.name.text = if (item.isSystemApp) {
|
||||
String.format("** %s", item.appName)
|
||||
} else {
|
||||
item.appName
|
||||
}
|
||||
binding.packageName.text = item.packageName
|
||||
binding.checkBox.isChecked = selectedPackages.contains(item.packageName)
|
||||
itemView.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
val packageName = appInfo.packageName
|
||||
if (selectedPackages.contains(packageName)) {
|
||||
selectedPackages.remove(packageName)
|
||||
} else {
|
||||
selectedPackages.add(packageName)
|
||||
}
|
||||
binding.checkBox.isChecked = selectedPackages.contains(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.WEBDAV_BACKUP_FILE_NAME
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityBackupBinding
|
||||
import com.v2ray.ang.databinding.DialogWebdavBinding
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.WebDavManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.ZipUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BackupActivity : HelperBaseActivity() {
|
||||
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
|
||||
|
||||
private val config_backup_options: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.config_backup_options)
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
|
||||
|
||||
binding.layoutBackup.setOnClickListener {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.title_configuration_backup)
|
||||
.setItems(config_backup_options) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> backupViaLocal()
|
||||
1 -> backupViaWebDav()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
binding.layoutShare.setOnClickListener {
|
||||
val ret = backupConfigurationToCache()
|
||||
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 {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.title_configuration_restore)
|
||||
.setItems(config_backup_options) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> restoreViaLocal()
|
||||
1 -> restoreViaWebDav()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
binding.layoutWebdavConfigSetting.setOnClickListener {
|
||||
showWebDavSettingsDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup configuration to cache directory
|
||||
* Returns Pair<success, zipFilePath>
|
||||
*/
|
||||
private fun backupConfigurationToCache(): Pair<Boolean, String> {
|
||||
val dateFormatted = SimpleDateFormat(
|
||||
"yyyy-MM-dd-HH-mm-ss",
|
||||
Locale.getDefault()
|
||||
).format(System.currentTimeMillis())
|
||||
val folderName = "${getString(R.string.app_name)}_${dateFormatted}"
|
||||
val backupDir = this.cacheDir.absolutePath + "/$folderName"
|
||||
val outputZipFilePath = "${this.cacheDir.absolutePath}/$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)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
|
||||
SettingsManager.initApp(this)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
private fun showFileChooser() {
|
||||
launchFileChooser { uri ->
|
||||
if (uri == null) {
|
||||
return@launchFileChooser
|
||||
}
|
||||
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) {
|
||||
LogUtil.e(AppConfig.TAG, "Error during file restore", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun backupViaLocal() {
|
||||
val dateFormatted = SimpleDateFormat(
|
||||
"yyyy-MM-dd-HH-mm-ss",
|
||||
Locale.getDefault()
|
||||
).format(System.currentTimeMillis())
|
||||
val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
|
||||
|
||||
launchCreateDocument(defaultFileName) { uri ->
|
||||
if (uri != null) {
|
||||
try {
|
||||
val ret = backupConfigurationToCache()
|
||||
if (ret.first) {
|
||||
// Copy the cached zip file to user-selected location
|
||||
contentResolver.openOutputStream(uri)?.use { output ->
|
||||
File(ret.second).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
// Clean up cache file
|
||||
File(ret.second).delete()
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to backup configuration", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreViaLocal() {
|
||||
showFileChooser()
|
||||
}
|
||||
|
||||
private fun backupViaWebDav() {
|
||||
val saved = MmkvManager.decodeWebDavConfig()
|
||||
if (saved == null || saved.baseUrl.isEmpty()) {
|
||||
toastError(R.string.title_webdav_config_setting_unknown)
|
||||
return
|
||||
}
|
||||
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
var tempFile: File? = null
|
||||
try {
|
||||
val ret = backupConfigurationToCache()
|
||||
if (!ret.first) {
|
||||
withContext(Dispatchers.Main) {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
tempFile = File(ret.second)
|
||||
WebDavManager.init(saved)
|
||||
|
||||
val ok = try {
|
||||
WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV upload error", e)
|
||||
false
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV backup error", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
tempFile?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreViaWebDav() {
|
||||
val saved = MmkvManager.decodeWebDavConfig()
|
||||
if (saved == null || saved.baseUrl.isEmpty()) {
|
||||
toastError(R.string.title_webdav_config_setting_unknown)
|
||||
return
|
||||
}
|
||||
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
var target: File? = null
|
||||
try {
|
||||
target = File(cacheDir, "download_${System.currentTimeMillis()}.zip")
|
||||
WebDavManager.init(saved)
|
||||
val ok = WebDavManager.downloadFile(WEBDAV_BACKUP_FILE_NAME, target)
|
||||
if (!ok) {
|
||||
withContext(Dispatchers.Main) {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val restored = restoreConfiguration(target)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (restored) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV download error", e)
|
||||
withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
|
||||
} finally {
|
||||
try {
|
||||
target?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showWebDavSettingsDialog() {
|
||||
val dialogBinding = DialogWebdavBinding.inflate(layoutInflater)
|
||||
|
||||
MmkvManager.decodeWebDavConfig()?.let { cfg ->
|
||||
dialogBinding.etWebdavUrl.setText(cfg.baseUrl)
|
||||
dialogBinding.etWebdavUser.setText(cfg.username ?: "")
|
||||
dialogBinding.etWebdavPass.setText(cfg.password ?: "")
|
||||
dialogBinding.etWebdavRemotePath.setText(cfg.remoteBasePath ?: "/")
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.title_webdav_config_setting)
|
||||
.setView(dialogBinding.root)
|
||||
.setPositiveButton(R.string.menu_item_save_config) { _, _ ->
|
||||
val url = dialogBinding.etWebdavUrl.text.toString().trim()
|
||||
val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
|
||||
val pass = dialogBinding.etWebdavPass.text.toString()
|
||||
val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
|
||||
val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
|
||||
MmkvManager.encodeWebDavConfig(cfg)
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.CustomDividerItemDecoration
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
|
||||
/**
|
||||
* BaseActivity provides common helpers and UI wiring used across the app's activities.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Inflate a shared base layout that contains a toolbar and a content container.
|
||||
* - Provide convenient overloads of `setContentViewWithToolbar` to attach child layouts or
|
||||
* view-binding roots into the base container and initialize the toolbar.
|
||||
* - Expose a global in-layout `ProgressBar` (cached) with `showLoading()` / `hideLoading()` helpers.
|
||||
* - Provide a helper to add a custom divider to RecyclerViews.
|
||||
* - Wrap base context according to user locale settings.
|
||||
*/
|
||||
abstract class BaseActivity : AppCompatActivity() {
|
||||
// Progress indicator that sits at the bottom of the toolbar
|
||||
private var progressBar: LinearProgressIndicator? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
if (!Utils.getDarkModeStatus(this)) {
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle action bar item selections.
|
||||
*
|
||||
* Currently this handles the home/up button by delegating to the activity's
|
||||
* onBackPressedDispatcher to provide consistent back navigation behavior.
|
||||
*
|
||||
* @param item the selected menu item
|
||||
* @return true if the event was handled, otherwise delegates to the superclass
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the base context with the user's locale settings.
|
||||
*
|
||||
* This ensures resources are loaded using the configured locale.
|
||||
*
|
||||
* @param newBase the original base context to wrap
|
||||
*/
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom divider drawable to the provided RecyclerView.
|
||||
*
|
||||
* This is a convenience helper that constructs a [CustomDividerItemDecoration]
|
||||
* using the given drawable resource id and adds it to the RecyclerView.
|
||||
*
|
||||
* @param recyclerView the target RecyclerView
|
||||
* @param context the context used to resolve resources (may be activity or application context)
|
||||
* @param drawableResId the drawable resource id to use as the divider
|
||||
* @param orientation one of [DividerItemDecoration.VERTICAL] or [DividerItemDecoration.HORIZONTAL]
|
||||
*
|
||||
* @throws IllegalArgumentException if the drawable resource cannot be found
|
||||
*/
|
||||
protected 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the toolbar instance using the default toolbar id if null is passed.
|
||||
*
|
||||
* This helper will set the toolbar as the action bar and configure the up button
|
||||
* visibility plus optional title.
|
||||
*
|
||||
* @param toolbar the toolbar instance to configure (may be null, in which case the view
|
||||
* with id R.id.toolbar in the activity content will be used)
|
||||
* @param showHomeAsUp whether the home/up affordance should be shown (default true)
|
||||
* @param title optional title to set on the activity
|
||||
*/
|
||||
protected fun setupToolbar(toolbar: Toolbar?, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val tb = toolbar ?: findViewById<Toolbar?>(R.id.toolbar)
|
||||
tb?.let {
|
||||
setSupportActionBar(it)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
|
||||
title?.let { t -> this.title = t }
|
||||
}
|
||||
progressBar = findViewById(R.id.progress_bar)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate the shared base layout, attach the child layout resource into the base
|
||||
* content container, cache the in-layout ProgressBar and configure the toolbar.
|
||||
*
|
||||
* Typical usage in subclasses:
|
||||
* setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = "Settings")
|
||||
*
|
||||
* @param layoutResId child layout resource to inflate into the base content container
|
||||
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
|
||||
* @param title optional activity title to set on the toolbar
|
||||
*/
|
||||
protected fun setContentViewWithToolbar(layoutResId: Int, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
|
||||
val container = base.findViewById<FrameLayout>(R.id.content_container)
|
||||
LayoutInflater.from(this).inflate(layoutResId, container, true)
|
||||
progressBar = base.findViewById(R.id.progress_bar)
|
||||
super.setContentView(base)
|
||||
setupToolbar(base, showHomeAsUp, title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate the shared base layout, attach the provided child view (commonly a view-binding root)
|
||||
* into the base content container, cache the in-layout ProgressBar and configure the toolbar.
|
||||
*
|
||||
* Typical usage with view binding:
|
||||
* setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "...")
|
||||
*
|
||||
* @param childView the already-inflated child view to add to the base content container
|
||||
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
|
||||
* @param title optional activity title to set on the toolbar
|
||||
*/
|
||||
protected fun setContentViewWithToolbar(childView: View, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
|
||||
val container = base.findViewById<FrameLayout>(R.id.content_container)
|
||||
container.addView(childView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
progressBar = base.findViewById(R.id.progress_bar)
|
||||
super.setContentView(base)
|
||||
setupToolbar(base, showHomeAsUp, title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper that configures the MaterialToolbar found in the inflated base root.
|
||||
*
|
||||
* @param baseRoot the root view of the inflated base layout
|
||||
* @param showHomeAsUp whether to show the up/home affordance
|
||||
* @param title optional title to set on the support action bar
|
||||
*/
|
||||
private fun setupToolbar(baseRoot: View, showHomeAsUp: Boolean, title: CharSequence?) {
|
||||
val toolbar = baseRoot.findViewById<MaterialToolbar>(R.id.toolbar)
|
||||
toolbar?.let {
|
||||
setSupportActionBar(it)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
|
||||
title?.let { t -> supportActionBar?.title = t }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the base layout's ProgressBar.
|
||||
*
|
||||
* This method is safe to call from background threads; the visibility change will
|
||||
* be posted to the UI thread via [runOnUiThread]. If the base layout was not set yet
|
||||
* (progressBar == null) the call is a no-op.
|
||||
*/
|
||||
protected fun showLoading() {
|
||||
runOnUiThread {
|
||||
progressBar?.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the base layout's ProgressBar.
|
||||
*
|
||||
* Safe to call from background threads. No-op if the progress bar hasn't been cached.
|
||||
*/
|
||||
protected fun hideLoading() {
|
||||
runOnUiThread {
|
||||
progressBar?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the base ProgressBar is currently visible.
|
||||
*
|
||||
* @return true if the progress bar exists and its visibility is VISIBLE
|
||||
*/
|
||||
protected fun isLoadingVisible(): Boolean {
|
||||
return progressBar?.visibility == View.VISIBLE
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.v2ray.ang.helper.CustomDividerItemDecoration
|
||||
|
||||
abstract class BaseFragment<VB : ViewBinding> : Fragment() {
|
||||
private var _binding: VB? = null
|
||||
protected val binding: VB
|
||||
get() = _binding!!
|
||||
|
||||
protected abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = inflateBinding(inflater, container)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom divider to a RecyclerView.
|
||||
*
|
||||
* @param recyclerView The target RecyclerView to which the divider will be added.
|
||||
* @param drawableResId The resource ID of the drawable to be used as the divider.
|
||||
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
|
||||
*/
|
||||
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
|
||||
// Get the drawable from resources
|
||||
val drawable = ContextCompat.getDrawable(requireContext(), 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)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
|
||||
import com.v2ray.ang.dto.CheckUpdateResult
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.UpdateCheckerManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
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)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, 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} (${CoreNativeManager.getLibVersion()})".also {
|
||||
binding.tvVersion.text = it
|
||||
}
|
||||
|
||||
checkForUpdates(binding.checkPreRelease.isChecked)
|
||||
}
|
||||
|
||||
private fun checkForUpdates(includePreRelease: Boolean) {
|
||||
toast(R.string.update_checking_for_update)
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
|
||||
if (result.hasUpdate) {
|
||||
showUpdateDialog(result)
|
||||
} else {
|
||||
toastSuccess(R.string.update_already_latest_version)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
|
||||
toastError(e.message ?: getString(R.string.toast_failure))
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
|
||||
/**
|
||||
* Pager adapter for subscription groups.
|
||||
*/
|
||||
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
|
||||
override fun getItemCount(): Int = groups.size
|
||||
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
|
||||
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
|
||||
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun update(groups: List<GroupMapItem>) {
|
||||
this.groups = groups
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.MainAdapterListener
|
||||
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
private val ownerActivity: MainActivity
|
||||
get() = requireActivity() as MainActivity
|
||||
private val mainViewModel: MainViewModel by activityViewModels()
|
||||
private lateinit var adapter: MainRecyclerAdapter
|
||||
private var itemTouchHelper: ItemTouchHelper? = null
|
||||
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
|
||||
|
||||
private val share_method: Array<out String> by lazy {
|
||||
ownerActivity.resources.getStringArray(R.array.share_method)
|
||||
}
|
||||
private val share_method_more: Array<out String> by lazy {
|
||||
ownerActivity.resources.getStringArray(R.array.share_method_more)
|
||||
}
|
||||
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
|
||||
ownerActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_SUB_ID = "subscriptionId"
|
||||
fun newInstance(subId: String) = GroupServerFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_SUB_ID, subId) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) =
|
||||
FragmentGroupServerBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
|
||||
} else {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
|
||||
}
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
||||
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
binding.refreshLayout.isEnabled = false
|
||||
// binding.refreshLayout.setOnRefreshListener(this)
|
||||
// // Set the distance to trigger sync to 160dp
|
||||
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
|
||||
|
||||
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
||||
if (mainViewModel.subscriptionId != subId) {
|
||||
return@observe
|
||||
}
|
||||
// LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||
adapter.setData(mainViewModel.serversCache, index)
|
||||
}
|
||||
|
||||
// LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainViewModel.subscriptionIdChanged(subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares server configuration
|
||||
* Displays a dialog with sharing options and executes the selected action
|
||||
* @param guid The server unique identifier
|
||||
* @param profile The server configuration
|
||||
* @param position The position in the list
|
||||
* @param shareOptions The list of share options
|
||||
* @param skip The number of options to skip
|
||||
*/
|
||||
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
|
||||
AlertDialog.Builder(ownerActivity).setItems(shareOptions.toTypedArray()) { _, i ->
|
||||
try {
|
||||
when (i + skip) {
|
||||
0 -> showQRCode(guid)
|
||||
1 -> share2Clipboard(guid)
|
||||
2 -> shareFullContent(guid)
|
||||
3 -> editServer(guid, profile)
|
||||
4 -> removeServer(guid, position)
|
||||
else -> ownerActivity.toast("else")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays QR code for the server configuration
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun showQRCode(guid: String) {
|
||||
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
|
||||
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
|
||||
if (share_method.isNotEmpty()) {
|
||||
ivBinding.ivQcode.contentDescription = share_method[0]
|
||||
} else {
|
||||
ivBinding.ivQcode.contentDescription = "QR Code"
|
||||
}
|
||||
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares server configuration to clipboard
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun share2Clipboard(guid: String) {
|
||||
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
|
||||
ownerActivity.toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
ownerActivity.toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares full server configuration content to clipboard
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun shareFullContent(guid: String) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result == 0) {
|
||||
ownerActivity.toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
ownerActivity.toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits server configuration
|
||||
* Opens appropriate editing interface based on configuration type
|
||||
* @param guid The server unique identifier
|
||||
* @param profile The server configuration
|
||||
*/
|
||||
private fun editServer(guid: String, profile: ProfileItem) {
|
||||
val activityClass = when (profile.configType) {
|
||||
EConfigType.CUSTOM -> ServerCustomConfigActivity::class.java
|
||||
EConfigType.POLICYGROUP -> ServerGroupActivity::class.java
|
||||
EConfigType.PROXYCHAIN -> ServerProxyChainActivity::class.java
|
||||
else -> ServerActivity::class.java
|
||||
}
|
||||
|
||||
val intent = Intent(ownerActivity, activityClass)
|
||||
.putExtra("guid", guid)
|
||||
.putExtra("isRunning", mainViewModel.isRunning.value)
|
||||
.putExtra("createConfigType", profile.configType.value)
|
||||
.putExtra("subscriptionId", subId)
|
||||
|
||||
launcher.launch(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes server configuration
|
||||
* Handles confirmation dialog and related checks
|
||||
* @param guid The server unique identifier
|
||||
* @param position The position in the list
|
||||
*/
|
||||
private fun removeServer(guid: String, position: Int) {
|
||||
if (guid == MmkvManager.getSelectServer()) {
|
||||
ownerActivity.toast(R.string.toast_action_not_allowed)
|
||||
return
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
removeServerSub(guid, position)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
//do noting
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
removeServerSub(guid, position)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the actual server removal process
|
||||
* @param guid The server unique identifier
|
||||
* @param position The position in the list
|
||||
*/
|
||||
private fun removeServerSub(guid: String, position: Int) {
|
||||
mainViewModel.removeServer(guid)
|
||||
adapter.removeServerSub(guid, position)
|
||||
ownerActivity.refreshGroupTabTitles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected server
|
||||
* Updates UI and restarts service if needed
|
||||
* @param guid The server unique identifier to select
|
||||
*/
|
||||
private fun setSelectServer(guid: String) {
|
||||
val selected = MmkvManager.getSelectServer()
|
||||
if (guid != selected) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
|
||||
val toPosition = mainViewModel.getPosition(guid)
|
||||
adapter.setSelectServer(fromPosition, toPosition)
|
||||
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
ownerActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ActivityAdapterListener : MainAdapterListener {
|
||||
override fun onEdit(guid: String, position: Int) {
|
||||
}
|
||||
|
||||
override fun onShare(url: String) {
|
||||
}
|
||||
|
||||
override fun onRefreshData() {
|
||||
}
|
||||
|
||||
override fun onRemove(guid: String, position: Int) {
|
||||
removeServer(guid, position)
|
||||
}
|
||||
|
||||
override fun onEdit(guid: String, position: Int, profile: ProfileItem) {
|
||||
editServer(guid, profile)
|
||||
}
|
||||
|
||||
override fun onSelectServer(guid: String) {
|
||||
setSelectServer(guid)
|
||||
}
|
||||
|
||||
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
|
||||
val isCustom = profile.configType.isComplexType()
|
||||
|
||||
val (shareOptions, skip) = if (more) {
|
||||
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
|
||||
options to if (isCustom) 2 else 0
|
||||
} else {
|
||||
val options = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
|
||||
options to if (isCustom) 2 else 0
|
||||
}
|
||||
|
||||
shareServer(guid, profile, position, shareOptions, skip)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
ownerActivity.importConfigViaSub()
|
||||
//binding.refreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the currently selected server in the RecyclerView
|
||||
*/
|
||||
fun scrollToSelectedServer() {
|
||||
val selectedGuid = MmkvManager.getSelectServer()
|
||||
if (selectedGuid.isNullOrEmpty()) {
|
||||
ownerActivity.toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the position of the selected server
|
||||
val serversCache = mainViewModel.serversCache
|
||||
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
|
||||
val recyclerView = binding.recyclerView
|
||||
|
||||
if (position >= 0) {
|
||||
// Get the layout manager
|
||||
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
|
||||
|
||||
if (layoutManager != null) {
|
||||
// Scroll to position with offset to center it on screen
|
||||
// First scroll to position, then adjust to center
|
||||
recyclerView.post {
|
||||
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
|
||||
}
|
||||
} else {
|
||||
// Fallback to smooth scroll if layout manager is not GridLayoutManager
|
||||
recyclerView.smoothScrollToPosition(position)
|
||||
}
|
||||
} else {
|
||||
ownerActivity.toast(R.string.toast_server_not_found_in_group)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityLogcatBinding
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
|
||||
private val viewModel: LogcatViewModel by viewModels()
|
||||
private lateinit var adapter: LogcatRecyclerAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
|
||||
|
||||
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
|
||||
|
||||
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)
|
||||
|
||||
toast(getString(R.string.pull_down_to_refresh))
|
||||
}
|
||||
|
||||
private fun onLogLongClick(log: String): Boolean {
|
||||
Utils.setClipboard(this, log)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun shareLogcat() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val logText = viewModel.getAll().joinToString("\n")
|
||||
|
||||
val result = try {
|
||||
val shareDir = File(cacheDir, "shared_logs").apply {
|
||||
mkdirs()
|
||||
}
|
||||
|
||||
shareDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
|
||||
val logFile = File(shareDir, "v2rayNG_logcat_$timestamp.txt")
|
||||
logFile.writeText(logText, Charsets.UTF_8)
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
this@LogcatActivity,
|
||||
"${packageName}.cache",
|
||||
logFile
|
||||
)
|
||||
|
||||
uri to logFile.name
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
toast(e.localizedMessage ?: e.toString())
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, result.first)
|
||||
putExtra(Intent.EXTRA_SUBJECT, result.second)
|
||||
putExtra(Intent.EXTRA_TITLE, result.second)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
clipData = ClipData.newUri(contentResolver, result.second, result.first)
|
||||
}
|
||||
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
getString(R.string.logcat_share)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_logcat, menu)
|
||||
|
||||
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 {
|
||||
viewModel.filter(newText)
|
||||
refreshData()
|
||||
return false
|
||||
}
|
||||
})
|
||||
searchView.setOnCloseListener {
|
||||
viewModel.filter("")
|
||||
refreshData()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.copy_all -> {
|
||||
val all = viewModel.getAll().joinToString("\n")
|
||||
Utils.setClipboard(this, all)
|
||||
toastSuccess(R.string.toast_success)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.share_all -> {
|
||||
shareLogcat()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.clear_all -> {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewModel.clearLogcat()
|
||||
withContext(Dispatchers.Main) {
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewModel.loadLogcat()
|
||||
withContext(Dispatchers.Main) {
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refreshData() {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||
|
||||
class LogcatRecyclerAdapter(
|
||||
private val viewModel: LogcatViewModel,
|
||||
private val onLongClick: ((String) -> Boolean)? = null
|
||||
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
|
||||
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
try {
|
||||
val logs = viewModel.getAll()
|
||||
val log = logs[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 ""
|
||||
}
|
||||
|
||||
holder.itemView.setOnLongClickListener {
|
||||
onLongClick?.invoke(log) ?: false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.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)
|
||||
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
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 com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.databinding.ActivityMainBinding
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.PermissionType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
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 : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
|
||||
private val binding by lazy {
|
||||
ActivityMainBinding.inflate(layoutInflater)
|
||||
}
|
||||
|
||||
val mainViewModel: MainViewModel by viewModels()
|
||||
private lateinit var groupPagerAdapter: GroupPagerAdapter
|
||||
private var tabMediator: TabLayoutMediator? = null
|
||||
|
||||
private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
private val requestActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
|
||||
restartV2Ray()
|
||||
}
|
||||
if (SettingsChangeManager.consumeSetupGroupTab()) {
|
||||
setupGroupTab()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
setupToolbar(binding.toolbar, false, getString(R.string.title_server))
|
||||
|
||||
// setup viewpager and tablayout
|
||||
groupPagerAdapter = GroupPagerAdapter(this, emptyList())
|
||||
binding.viewPager.adapter = groupPagerAdapter
|
||||
binding.viewPager.isUserInputEnabled = true
|
||||
|
||||
// setup navigation drawer
|
||||
setupNavigationDrawer()
|
||||
|
||||
binding.fab.setOnClickListener { handleFabAction() }
|
||||
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
|
||||
|
||||
setupGroupTab()
|
||||
setupViewModel()
|
||||
SubscriptionUpdater.sync()
|
||||
mainViewModel.reloadServerList()
|
||||
|
||||
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupNavigationDrawer() {
|
||||
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)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setupViewModel() {
|
||||
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
|
||||
mainViewModel.isRunning.observe(this) { isRunning ->
|
||||
applyRunningState(false, isRunning)
|
||||
}
|
||||
mainViewModel.startListenBroadcast()
|
||||
mainViewModel.initAssets(assets)
|
||||
}
|
||||
|
||||
private fun setupGroupTab() {
|
||||
val groups = mainViewModel.getSubscriptions(this)
|
||||
groupPagerAdapter.update(groups)
|
||||
|
||||
tabMediator?.detach()
|
||||
tabMediator = TabLayoutMediator(binding.tabGroup, binding.viewPager) { tab, position ->
|
||||
groupPagerAdapter.groups.getOrNull(position)?.let {
|
||||
tab.text = it.remarks
|
||||
tab.tag = it.id
|
||||
}
|
||||
}.also { it.attach() }
|
||||
|
||||
val targetIndex = groups.indexOfFirst { it.id == mainViewModel.subscriptionId }.takeIf { it >= 0 } ?: (groups.size - 1)
|
||||
binding.viewPager.setCurrentItem(targetIndex, false)
|
||||
|
||||
binding.tabGroup.isVisible = groups.size > 1
|
||||
refreshGroupTabTitles(true)
|
||||
}
|
||||
|
||||
fun refreshGroupTabTitles(refreshAll: Boolean = false) {
|
||||
val groupsToRefresh = if (refreshAll || mainViewModel.subscriptionId.isEmpty()) {
|
||||
groupPagerAdapter.groups
|
||||
} else {
|
||||
groupPagerAdapter.groups.filter { it.id == mainViewModel.subscriptionId }
|
||||
}
|
||||
|
||||
groupsToRefresh.forEach { group ->
|
||||
if (group.id.isEmpty()) {
|
||||
return@forEach
|
||||
}
|
||||
val tabIndex = groupPagerAdapter.groups.indexOfFirst { it.id == group.id }
|
||||
if (tabIndex >= 0) {
|
||||
val count = MmkvManager.decodeServerList(group.id).size
|
||||
binding.tabGroup.getTabAt(tabIndex)?.text = "${group.remarks} ($count)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFabAction() {
|
||||
applyRunningState(isLoading = true, isRunning = false)
|
||||
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
CoreServiceManager.stopVService(this)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) {
|
||||
startV2Ray()
|
||||
} else {
|
||||
requestVpnPermission.launch(intent)
|
||||
}
|
||||
} else {
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLayoutTestClick() {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
setTestState(getString(R.string.connection_test_testing))
|
||||
mainViewModel.testCurrentServerRealPing()
|
||||
} else {
|
||||
// service not running: keep existing no-op (could show a message if desired)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startV2Ray() {
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
checkAndRequestPermission(PermissionType.ACCESS_LOCAL_NETWORK) {}
|
||||
}
|
||||
|
||||
CoreServiceManager.startVService(this)
|
||||
}
|
||||
|
||||
fun restartV2Ray() {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
CoreServiceManager.stopVService(this)
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
delay(500)
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTestState(content: String?) {
|
||||
binding.tvTestState.text = content
|
||||
}
|
||||
|
||||
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
|
||||
if (isLoading) {
|
||||
binding.fab.setImageResource(R.drawable.ic_fab_check)
|
||||
return
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
|
||||
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
|
||||
binding.fab.contentDescription = getString(R.string.action_stop_service)
|
||||
setTestState(getString(R.string.connection_connected))
|
||||
binding.layoutTest.isFocusable = true
|
||||
} else {
|
||||
binding.fab.setImageResource(R.drawable.ic_play_24dp)
|
||||
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
|
||||
binding.fab.contentDescription = getString(R.string.tasker_start_service)
|
||||
setTestState(getString(R.string.connection_not_connected))
|
||||
binding.layoutTest.isFocusable = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
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_policy_group -> {
|
||||
importManually(EConfigType.POLICYGROUP.value)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.import_manually_proxy_chain -> {
|
||||
importManually(EConfigType.PROXYCHAIN.value)
|
||||
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.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
|
||||
}
|
||||
|
||||
R.id.locate_selected_config -> {
|
||||
locateSelectedServer()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun importManually(createConfigType: Int) {
|
||||
if (createConfigType == EConfigType.POLICYGROUP.value) {
|
||||
startActivity(
|
||||
Intent()
|
||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||
.setClass(this, ServerGroupActivity::class.java)
|
||||
)
|
||||
} else if (createConfigType == EConfigType.PROXYCHAIN.value) {
|
||||
startActivity(
|
||||
Intent()
|
||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||
.setClass(this, ServerProxyChainActivity::class.java)
|
||||
)
|
||||
} else {
|
||||
startActivity(
|
||||
Intent()
|
||||
.putExtra("createConfigType", createConfigType)
|
||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||
.setClass(this, ServerActivity::class.java)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* import config from qrcode
|
||||
*/
|
||||
private fun importQRcode(): Boolean {
|
||||
launchQRCodeScanner { scanResult ->
|
||||
if (scanResult != null) {
|
||||
importBatchConfig(scanResult)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* import config from clipboard
|
||||
*/
|
||||
private fun importClipboard()
|
||||
: Boolean {
|
||||
try {
|
||||
val clipboard = Utils.getClipboard(this)
|
||||
importBatchConfig(clipboard)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to import config from clipboard", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun importBatchConfig(server: String?) {
|
||||
showLoading()
|
||||
|
||||
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()
|
||||
refreshGroupTabTitles()
|
||||
}
|
||||
|
||||
countSub > 0 -> setupGroupTab()
|
||||
else -> toastError(R.string.toast_failure)
|
||||
}
|
||||
hideLoading()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
toastError(R.string.toast_failure)
|
||||
hideLoading()
|
||||
}
|
||||
LogUtil.e(AppConfig.TAG, "Failed to import batch config", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* import config from local config file
|
||||
*/
|
||||
private fun importConfigLocal(): Boolean {
|
||||
try {
|
||||
showFileChooser()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to import config from local file", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* import config from sub
|
||||
*/
|
||||
fun importConfigViaSub(): Boolean {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = mainViewModel.updateConfigViaSubAll()
|
||||
delay(500L)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||
toast(R.string.title_update_subscription_no_subscription)
|
||||
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||
} else {
|
||||
toast(
|
||||
getString(
|
||||
R.string.title_update_subscription_result,
|
||||
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||
)
|
||||
)
|
||||
}
|
||||
if (result.configCount > 0) {
|
||||
mainViewModel.reloadServerList()
|
||||
refreshGroupTabTitles()
|
||||
}
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun exportAll() {
|
||||
showLoading()
|
||||
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)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun delAllConfig() {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val ret = mainViewModel.removeAllServer()
|
||||
launch(Dispatchers.Main) {
|
||||
mainViewModel.reloadServerList()
|
||||
refreshGroupTabTitles()
|
||||
toast(getString(R.string.title_del_config_count, ret))
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
.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) { _, _ ->
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val ret = mainViewModel.removeDuplicateServer()
|
||||
launch(Dispatchers.Main) {
|
||||
mainViewModel.reloadServerList()
|
||||
refreshGroupTabTitles()
|
||||
toast(getString(R.string.title_del_duplicate_config_count, ret))
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
.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) { _, _ ->
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val ret = mainViewModel.removeInvalidServer()
|
||||
launch(Dispatchers.Main) {
|
||||
mainViewModel.reloadServerList()
|
||||
refreshGroupTabTitles()
|
||||
toast(getString(R.string.title_del_config_count, ret))
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
//do noting
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun sortByTestResults() {
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
mainViewModel.sortByTestResults()
|
||||
launch(Dispatchers.Main) {
|
||||
mainViewModel.reloadServerList()
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* show file chooser
|
||||
*/
|
||||
private fun showFileChooser() {
|
||||
launchFileChooser { uri ->
|
||||
if (uri == null) {
|
||||
return@launchFileChooser
|
||||
}
|
||||
|
||||
readContentFromUri(uri)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* read content from uri
|
||||
*/
|
||||
private fun readContentFromUri(uri: Uri) {
|
||||
try {
|
||||
contentResolver.openInputStream(uri).use { input ->
|
||||
importBatchConfig(input?.bufferedReader()?.readText())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to read content from URI", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates and scrolls to the currently selected server.
|
||||
* If the selected server is in a different group, automatically switches to that group first.
|
||||
*/
|
||||
private fun locateSelectedServer() {
|
||||
val targetSubscriptionId = mainViewModel.findSubscriptionIdBySelect()
|
||||
if (targetSubscriptionId.isNullOrEmpty()) {
|
||||
toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
|
||||
val targetGroupIndex = groupPagerAdapter.groups.indexOfFirst { it.id == targetSubscriptionId }
|
||||
if (targetGroupIndex < 0) {
|
||||
toast(R.string.toast_server_not_found_in_group)
|
||||
return
|
||||
}
|
||||
|
||||
// Switch to target group if needed, then scroll to the server
|
||||
if (binding.viewPager.currentItem != targetGroupIndex) {
|
||||
binding.viewPager.setCurrentItem(targetGroupIndex, true)
|
||||
binding.viewPager.postDelayed({ scrollToSelectedServer(targetGroupIndex) }, 1000)
|
||||
} else {
|
||||
scrollToSelectedServer(targetGroupIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the selected server in the specified fragment.
|
||||
* @param groupIndex The index of the group/fragment to scroll in
|
||||
*/
|
||||
private fun scrollToSelectedServer(groupIndex: Int) {
|
||||
val itemId = groupPagerAdapter.getItemId(groupIndex)
|
||||
val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment
|
||||
|
||||
if (fragment?.isAdded == true && fragment.view != null) {
|
||||
fragment.scrollToSelectedServer()
|
||||
} else {
|
||||
toast(R.string.toast_fragment_not_available)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
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 -> requestActivityLauncher.launch(Intent(this, SubSettingActivity::class.java))
|
||||
R.id.per_app_proxy_settings -> requestActivityLauncher.launch(Intent(this, PerAppProxyActivity::class.java))
|
||||
R.id.routing_setting -> requestActivityLauncher.launch(Intent(this, RoutingSettingActivity::class.java))
|
||||
R.id.user_asset_setting -> requestActivityLauncher.launch(Intent(this, UserAssetActivity::class.java))
|
||||
R.id.settings -> requestActivityLauncher.launch(Intent(this, SettingsActivity::class.java))
|
||||
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.backup_restore -> requestActivityLauncher.launch(Intent(this, BackupActivity::class.java))
|
||||
R.id.about -> startActivity(Intent(this, AboutActivity::class.java))
|
||||
}
|
||||
|
||||
binding.drawerLayout.closeDrawer(GravityCompat.START)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
tabMediator?.detach()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.MainAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
|
||||
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
import java.util.Collections
|
||||
|
||||
class MainRecyclerAdapter(
|
||||
private val mainViewModel: MainViewModel,
|
||||
private val adapterListener: MainAdapterListener?
|
||||
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
|
||||
companion object {
|
||||
private const val VIEW_TYPE_ITEM = 1
|
||||
private const val VIEW_TYPE_FOOTER = 2
|
||||
}
|
||||
|
||||
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
|
||||
private var data: MutableList<ServersCache> = mutableListOf()
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
|
||||
data = newData?.toMutableList() ?: mutableListOf()
|
||||
|
||||
if (position >= 0 && position in data.indices) {
|
||||
notifyItemChanged(position)
|
||||
} else {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = data.size + 1
|
||||
|
||||
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
||||
if (holder is MainViewHolder) {
|
||||
val context = holder.itemMainBinding.root.context
|
||||
val guid = data[position].guid
|
||||
val profile = data[position].profile
|
||||
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
//Name address
|
||||
holder.itemMainBinding.tvName.text = profile.remarks
|
||||
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
|
||||
holder.itemMainBinding.tvType.text = getProtocolDescription(profile)
|
||||
|
||||
//TestResult
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
|
||||
if ((aff?.testDelayMillis ?: 0L) < 0L) {
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
|
||||
} else {
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
|
||||
}
|
||||
|
||||
//layoutIndicator
|
||||
if (guid == MmkvManager.getSelectServer()) {
|
||||
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorIndicator)
|
||||
} 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
|
||||
|
||||
holder.itemMainBinding.layoutMore.setOnClickListener {
|
||||
adapterListener?.onShare(guid, profile, position, true)
|
||||
}
|
||||
} 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
|
||||
|
||||
holder.itemMainBinding.layoutShare.setOnClickListener {
|
||||
adapterListener?.onShare(guid, profile, position, false)
|
||||
}
|
||||
|
||||
holder.itemMainBinding.layoutEdit.setOnClickListener {
|
||||
adapterListener?.onEdit(guid, position, profile)
|
||||
}
|
||||
holder.itemMainBinding.layoutRemove.setOnClickListener {
|
||||
adapterListener?.onRemove(guid, position)
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemMainBinding.infoContainer.setOnClickListener {
|
||||
adapterListener?.onSelectServer(guid)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (mainViewModel.subscriptionId.isEmpty())
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
||||
else
|
||||
null
|
||||
return subRemarks?.toString() ?: ""
|
||||
}
|
||||
|
||||
private fun getProtocolDescription(profile: ProfileItem): String {
|
||||
if (profile.configType.isComplexType()) {
|
||||
return profile.configType.name
|
||||
}
|
||||
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add(profile.configType.name)
|
||||
|
||||
// Transport: hide tcp or blank
|
||||
profile.network?.let { net ->
|
||||
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) {
|
||||
parts.add(net)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: hide blank or tls
|
||||
profile.security?.let { sec ->
|
||||
if (sec.isNotBlank()) {
|
||||
if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) {
|
||||
parts.add("$sec insecure") // TODO
|
||||
} else {
|
||||
parts.add(sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.joinToString(" / ")
|
||||
}
|
||||
|
||||
fun removeServerSub(guid: String, position: Int) {
|
||||
val idx = data.indexOfFirst { it.guid == guid }
|
||||
if (idx >= 0) {
|
||||
data.removeAt(idx)
|
||||
notifyItemRemoved(idx)
|
||||
notifyItemRangeChanged(idx, data.size - idx)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectServer(fromPosition: Int, toPosition: Int) {
|
||||
notifyItemChanged(fromPosition)
|
||||
notifyItemChanged(toPosition)
|
||||
}
|
||||
|
||||
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 == data.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 {
|
||||
mainViewModel.swapServer(fromPosition, toPosition)
|
||||
if (fromPosition < data.size && toPosition < data.size) {
|
||||
Collections.swap(data, fromPosition, toPosition)
|
||||
}
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
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.dto.UrlContentRequest
|
||||
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.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
import es.dmoral.toasty.Toasty
|
||||
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<AppInfo>? = null
|
||||
private val viewModel: PerAppProxyViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.per_app_proxy_settings))
|
||||
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
|
||||
initList()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initList() {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
|
||||
|
||||
val blacklistSet = viewModel.getAll()
|
||||
if (blacklistSet.isNotEmpty()) {
|
||||
appsList.forEach { app ->
|
||||
app.isSelected = if (blacklistSet.contains(app.packageName)) 1 else 0
|
||||
}
|
||||
appsList.sortedWith { p1, p2 ->
|
||||
when {
|
||||
p1.isSelected > p2.isSelected -> -1
|
||||
p1.isSelected < p2.isSelected -> 1
|
||||
p1.isSystemApp > p2.isSystemApp -> 1
|
||||
p1.isSystemApp < p2.isSystemApp -> -1
|
||||
p1.appName.lowercase() > p2.appName.lowercase() -> 1
|
||||
p1.appName.lowercase() < p2.appName.lowercase() -> -1
|
||||
p1.packageName > p2.packageName -> 1
|
||||
p1.packageName < p2.packageName -> -1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val collator = Collator.getInstance()
|
||||
appsList.sortedWith(compareBy(collator) { it.appName })
|
||||
}
|
||||
}
|
||||
|
||||
appsAll = apps
|
||||
adapter = PerAppProxyAdapter(apps, viewModel)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 -> {
|
||||
selectAllApp()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.invert_selection -> {
|
||||
invertSelection()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.select_proxy_app -> {
|
||||
selectProxyAppAuto()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.import_proxy_app -> {
|
||||
importProxyApp()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.export_proxy_app -> {
|
||||
exportProxyApp()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun selectAllApp() {
|
||||
adapter?.let { adapter ->
|
||||
val pkgNames = adapter.apps.map { it.packageName }
|
||||
val allSelected = pkgNames.all { viewModel.contains(it) }
|
||||
|
||||
if (allSelected) {
|
||||
viewModel.removeAll(pkgNames)
|
||||
} else {
|
||||
viewModel.addAll(pkgNames)
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invertSelection() {
|
||||
adapter?.let { adapter ->
|
||||
adapter.apps.forEach { app ->
|
||||
viewModel.toggle(app.packageName)
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectProxyAppAuto() {
|
||||
toast(R.string.msg_downloading_content)
|
||||
showLoading()
|
||||
|
||||
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
var content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000
|
||||
)
|
||||
)
|
||||
if (content.isNullOrEmpty()) {
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
) ?: ""
|
||||
}
|
||||
launch(Dispatchers.Main) {
|
||||
//LogUtil.i(AppConfig.TAG, content)
|
||||
selectProxyApp(content, true)
|
||||
toastSuccess(R.string.toast_success)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
viewModel.getAll().forEach { pkg ->
|
||||
lst = lst + System.lineSeparator() + pkg
|
||||
}
|
||||
Utils.setClipboard(applicationContext, lst)
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
|
||||
private fun allowPerAppProxy() {
|
||||
binding.switchPerAppProxy.isChecked = true
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun selectProxyApp(content: String, force: Boolean): Boolean {
|
||||
try {
|
||||
val proxyApps = if (TextUtils.isEmpty(content)) {
|
||||
Utils.readTextFromAssets(v2RayApplication, "proxy_package_name")
|
||||
} else {
|
||||
content
|
||||
}
|
||||
if (TextUtils.isEmpty(proxyApps)) return false
|
||||
|
||||
viewModel.clear()
|
||||
|
||||
if (binding.switchBypassApps.isChecked) {
|
||||
adapter?.let { adapter ->
|
||||
adapter.apps.forEach { app ->
|
||||
val packageName = app.packageName
|
||||
if (!inProxyApps(proxyApps, packageName, force)) {
|
||||
viewModel.add(packageName)
|
||||
}
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
} else {
|
||||
adapter?.let { adapter ->
|
||||
adapter.apps.forEach { app ->
|
||||
val packageName = app.packageName
|
||||
if (inProxyApps(proxyApps, packageName, force)) {
|
||||
viewModel.add(packageName)
|
||||
}
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
|
||||
println(packageName)
|
||||
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<AppInfo>()
|
||||
|
||||
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(apps, adapter?.viewModel ?: viewModel)
|
||||
binding.recyclerView.adapter = adapter
|
||||
refreshData()
|
||||
return true
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refreshData() {
|
||||
adapter?.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
|
||||
class PerAppProxyAdapter(
|
||||
val apps: List<AppInfo>,
|
||||
val viewModel: PerAppProxyViewModel
|
||||
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_HEADER = 0
|
||||
private const val VIEW_TYPE_ITEM = 1
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
||||
if (holder is AppViewHolder) {
|
||||
val appInfo = apps[position - 1]
|
||||
holder.bind(appInfo)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = apps.size + 1
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
|
||||
val ctx = parent.context
|
||||
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_HEADER -> {
|
||||
val view = View(ctx)
|
||||
view.layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0
|
||||
)
|
||||
BaseViewHolder(view)
|
||||
}
|
||||
|
||||
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = if (position == 0) VIEW_TYPE_HEADER else VIEW_TYPE_ITEM
|
||||
|
||||
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
|
||||
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
|
||||
View.OnClickListener {
|
||||
private lateinit var appInfo: AppInfo
|
||||
|
||||
fun bind(appInfo: AppInfo) {
|
||||
this.appInfo = appInfo
|
||||
|
||||
itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
|
||||
itemBypassBinding.name.text = if (appInfo.isSystemApp) {
|
||||
String.format("** %s", appInfo.appName)
|
||||
} else {
|
||||
appInfo.appName
|
||||
}
|
||||
|
||||
itemBypassBinding.packageName.text = appInfo.packageName
|
||||
itemBypassBinding.checkBox.isChecked = viewModel.contains(appInfo.packageName)
|
||||
|
||||
itemView.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
val packageName = appInfo.packageName
|
||||
viewModel.toggle(packageName)
|
||||
itemBypassBinding.checkBox.isChecked = viewModel.contains(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.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 processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
|
||||
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
||||
|
||||
setupOutboundTagInput()
|
||||
setupProcessPicker()
|
||||
|
||||
val rulesetItem = SettingsManager.getRoutingRuleset(position)
|
||||
if (rulesetItem != null) {
|
||||
bindingServer(rulesetItem)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
|
||||
SettingsManager.canUseProcessRouting().let { canUse ->
|
||||
binding.etProcess.isEnabled = canUse
|
||||
binding.btnProcessPicker.isEnabled = canUse
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupProcessPicker() {
|
||||
binding.btnProcessPicker.setOnClickListener {
|
||||
processPickerLauncher.launch(
|
||||
AppPickerActivity.createIntent(
|
||||
context = this,
|
||||
selectedPackages = getSelectedProcessPackages(),
|
||||
title = getString(R.string.routing_settings_process)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectedProcessPackages(): List<String> {
|
||||
return binding.etProcess.text
|
||||
.toString()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the AutoCompleteTextView for outbound tag:
|
||||
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
|
||||
* The dropdown button triggers showing the full list without typing.
|
||||
*/
|
||||
private fun setupOutboundTagInput() {
|
||||
val profileRemarks = SettingsManager.getProfileRemarks()
|
||||
|
||||
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
|
||||
binding.spOutboundTag.setAdapter(adapter)
|
||||
// threshold=0 means show all suggestions even before typing; still need focus+request
|
||||
binding.spOutboundTag.threshold = 0
|
||||
|
||||
// Dropdown arrow button shows the full suggestion list
|
||||
binding.btnOutboundTagDropdown.setOnClickListener {
|
||||
binding.spOutboundTag.requestFocus()
|
||||
binding.spOutboundTag.showDropDown()
|
||||
}
|
||||
// Also show on field click when it already has focus
|
||||
binding.spOutboundTag.setOnClickListener {
|
||||
binding.spOutboundTag.showDropDown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
|
||||
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.etProcess.text = Utils.getEditable(rulesetItem.process?.joinToString(","))
|
||||
binding.etPort.text = Utils.getEditable(rulesetItem.port)
|
||||
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
|
||||
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
|
||||
// Set text directly; filter won't fire because we're not using setText(filter=true)
|
||||
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
|
||||
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().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
process = binding.etProcess.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
port = binding.etPort.text.toString().nullIfBlank()
|
||||
network = binding.etNetwork.text.toString().nullIfBlank()
|
||||
outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY }
|
||||
}
|
||||
|
||||
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 delConfig = menu.findItem(R.id.del_config)
|
||||
|
||||
if (position < 0) {
|
||||
delConfig?.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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
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.activity.viewModels
|
||||
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.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
|
||||
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.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RoutingSettingActivity : HelperBaseActivity() {
|
||||
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
|
||||
private val ownerActivity: RoutingSettingActivity
|
||||
get() = this
|
||||
private val viewModel: RoutingSettingsViewModel by viewModels()
|
||||
private lateinit var adapter: RoutingSettingRecyclerAdapter
|
||||
private var mItemTouchHelper: ItemTouchHelper? = null
|
||||
private val routing_domain_strategy: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.routing_domain_strategy)
|
||||
}
|
||||
private val preset_rulesets: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.preset_rulesets)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
|
||||
|
||||
adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
|
||||
|
||||
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 -> importQRcode()
|
||||
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) {
|
||||
LogUtil.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) {
|
||||
LogUtil.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) {
|
||||
LogUtil.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 importQRcode(): Boolean {
|
||||
launchQRCodeScanner { scanResult ->
|
||||
if (scanResult != null) {
|
||||
importRulesetsFromQRcode(scanResult)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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 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() {
|
||||
viewModel.reload()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||
override fun onEdit(guid: String, position: Int) {
|
||||
startActivity(
|
||||
Intent(ownerActivity, RoutingEditActivity::class.java)
|
||||
.putExtra("position", position)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRemove(guid: String, position: Int) {
|
||||
}
|
||||
|
||||
override fun onShare(url: String) {
|
||||
}
|
||||
|
||||
override fun onRefreshData() {
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
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.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||
|
||||
class RoutingSettingRecyclerAdapter(
|
||||
private val viewModel: RoutingSettingsViewModel,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
|
||||
ItemTouchHelperAdapter {
|
||||
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
val rulesets = viewModel.getAll()
|
||||
val ruleset = rulesets[position]
|
||||
|
||||
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
|
||||
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.process ?: ruleset.port)?.toString()
|
||||
holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
|
||||
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
|
||||
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
|
||||
adapterListener?.onEdit("", position)
|
||||
}
|
||||
|
||||
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||
if (!it.isPressed) return@setOnCheckedChangeListener
|
||||
ruleset.enabled = isChecked
|
||||
viewModel.update(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 {
|
||||
viewModel.swap(fromPosition, toPosition)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class ScStartActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
moveTaskToBack(true)
|
||||
|
||||
setContentView(R.layout.activity_none)
|
||||
|
||||
if (!CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.startVServiceFromToggle(this)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class ScStopActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
moveTaskToBack(true)
|
||||
|
||||
setContentView(R.layout.activity_none)
|
||||
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.stopVService(this)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class ScSwitchActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
moveTaskToBack(true)
|
||||
|
||||
setContentView(R.layout.activity_none)
|
||||
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.stopVService(this)
|
||||
} else {
|
||||
CoreServiceManager.startVServiceFromToggle(this)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -3,54 +3,109 @@ package com.v2ray.ang.ui
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.camera.compose.CameraXViewfinder
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraControl
|
||||
import androidx.camera.core.CameraInfo
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceRequest
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||
import androidx.camera.core.resolutionselector.ResolutionStrategy
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.viewfinder.compose.MutableCoordinateTransformer
|
||||
import androidx.camera.viewfinder.core.ImplementationMode
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityNoneBinding
|
||||
import com.v2ray.ang.enums.PermissionType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import io.github.g00fy2.quickie.QRResult
|
||||
import io.github.g00fy2.quickie.ScanCustomCode
|
||||
import io.github.g00fy2.quickie.config.BarcodeFormat
|
||||
import io.github.g00fy2.quickie.config.ScannerConfig
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import android.util.Size as TargetSize
|
||||
|
||||
class ScannerActivity : HelperBaseActivity() {
|
||||
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
|
||||
private val qrReader = MultiFormatReader()
|
||||
|
||||
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
|
||||
class ScannerActivity : HelperBaseComponentActivity() {
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
private val uiState = mutableStateOf(ScannerUiState.IDLE)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.menu_item_import_config_qrcode))
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) {
|
||||
launchScan()
|
||||
}
|
||||
startScan()
|
||||
}
|
||||
|
||||
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
|
||||
setBarcodeFormats(listOf(BarcodeFormat.QR_CODE))
|
||||
}
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
ScannerScreen(
|
||||
uiState = uiState.value,
|
||||
onBackClick = { finish() },
|
||||
onSelectPhoto = { showFileChooser() },
|
||||
onStartScan = { startScan() },
|
||||
onStopScan = { stopScan() },
|
||||
onScanResult = { text -> finished(text) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleResult(result: QRResult) {
|
||||
if (result is QRResult.QRSuccess) {
|
||||
finished(result.content.rawValue.orEmpty())
|
||||
} else {
|
||||
finish()
|
||||
private fun startScan() {
|
||||
checkAndRequestPermission(PermissionType.CAMERA) {
|
||||
uiState.value = ScannerUiState.ACTIVE
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScan() {
|
||||
uiState.value = ScannerUiState.IDLE
|
||||
}
|
||||
|
||||
private fun finished(text: String) {
|
||||
val intent = Intent()
|
||||
intent.putExtra("SCAN_RESULT", text)
|
||||
@@ -58,36 +113,13 @@ class ScannerActivity : HelperBaseActivity() {
|
||||
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 -> {
|
||||
showFileChooser()
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun showFileChooser() {
|
||||
launchFileChooser("image/*") { uri ->
|
||||
if (uri == null) {
|
||||
return@launchFileChooser
|
||||
}
|
||||
if (uri == null) return@launchFileChooser
|
||||
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)
|
||||
@@ -100,4 +132,308 @@ class ScannerActivity : HelperBaseActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ScannerUiState {
|
||||
IDLE,
|
||||
ACTIVE
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScannerScreen(
|
||||
uiState: ScannerUiState,
|
||||
onBackClick: () -> Unit,
|
||||
onSelectPhoto: () -> Unit,
|
||||
onStartScan: () -> Unit,
|
||||
onStopScan: () -> Unit,
|
||||
onScanResult: (String) -> Unit
|
||||
) {
|
||||
val isScanning = uiState == ScannerUiState.ACTIVE
|
||||
var cameraControl by remember { mutableStateOf<CameraControl?>(null) }
|
||||
var hasTorch by remember { mutableStateOf(false) }
|
||||
var torchEnabled by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.menu_item_import_config_qrcode),
|
||||
onBackClick = onBackClick,
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isScanning) {
|
||||
if (torchEnabled) {
|
||||
torchEnabled = false
|
||||
cameraControl?.enableTorch(false)
|
||||
}
|
||||
onStopScan()
|
||||
} else {
|
||||
onStartScan()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painterResource(
|
||||
if (isScanning) R.drawable.ic_stop_24dp
|
||||
else R.drawable.ic_scan_24dp
|
||||
),
|
||||
contentDescription = if (isScanning) "stop scan" else "start scan"
|
||||
)
|
||||
}
|
||||
if (isScanning && hasTorch) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
torchEnabled = !torchEnabled
|
||||
cameraControl?.enableTorch(torchEnabled)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painterResource(
|
||||
if (torchEnabled) R.drawable.ic_flash_on_24dp
|
||||
else R.drawable.ic_flash_off_24dp
|
||||
),
|
||||
contentDescription = "Torch"
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSelectPhoto) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_image_24dp),
|
||||
contentDescription = "select image"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (uiState) {
|
||||
ScannerUiState.IDLE -> ScannerIdlePlaceholder(onStartClick = onStartScan)
|
||||
ScannerUiState.ACTIVE -> {
|
||||
CameraXPreview(
|
||||
onScanResult = onScanResult,
|
||||
onCameraReady = { control, info ->
|
||||
cameraControl = control
|
||||
hasTorch = info.hasFlashUnit()
|
||||
if (torchEnabled && hasTorch) {
|
||||
control.enableTorch(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
ScannerOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScannerIdlePlaceholder(onStartClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.pointerInput(Unit) { detectTapGestures { onStartClick() } },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_scan_24dp),
|
||||
contentDescription = "Start Scanner",
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.menu_item_scan_qrcode),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.summary_scan_qrcode),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScannerOverlay() {
|
||||
val scanBoxSize = 250.dp
|
||||
val cornerLength = 24.dp
|
||||
val cornerWidth = 3.dp
|
||||
val cornerColor = Color(0xFF4CAF50)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
val boxSizePx = scanBoxSize.toPx()
|
||||
val left = (canvasWidth - boxSizePx) / 2f
|
||||
val top = (canvasHeight - boxSizePx) / 2f
|
||||
drawRect(color = Color(0x80000000))
|
||||
drawRect(
|
||||
color = Color.Transparent,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(boxSizePx, boxSizePx),
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(scanBoxSize)
|
||||
.align(Alignment.Center)
|
||||
) {
|
||||
listOf(
|
||||
Alignment.TopStart,
|
||||
Alignment.TopEnd,
|
||||
Alignment.BottomStart,
|
||||
Alignment.BottomEnd
|
||||
).forEach { align ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(align)
|
||||
.width(cornerLength)
|
||||
.height(cornerWidth)
|
||||
.background(cornerColor)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(align)
|
||||
.width(cornerWidth)
|
||||
.height(cornerLength)
|
||||
.background(cornerColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.menu_item_scan_qrcode),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(y = scanBoxSize / 2 + 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraXPreview(
|
||||
onScanResult: (String) -> Unit,
|
||||
onCameraReady: (CameraControl, CameraInfo) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val foundResult = remember { AtomicBoolean(false) }
|
||||
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
var cameraProvider: ProcessCameraProvider? = null
|
||||
var camera: Camera? = null
|
||||
|
||||
val resolutionSelector = ResolutionSelector.Builder()
|
||||
.setResolutionStrategy(
|
||||
ResolutionStrategy(
|
||||
TargetSize(1280, 720),
|
||||
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setResolutionSelector(resolutionSelector)
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.apply {
|
||||
setAnalyzer(analysisExecutor) { imageProxy ->
|
||||
processImageProxy(imageProxy, foundResult, onScanResult)
|
||||
}
|
||||
}
|
||||
|
||||
val preview = Preview.Builder().build()
|
||||
preview.setSurfaceProvider { request -> surfaceRequest = request }
|
||||
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener(
|
||||
{
|
||||
try {
|
||||
cameraProvider = cameraProviderFuture.get()
|
||||
cameraProvider?.unbindAll()
|
||||
camera = cameraProvider?.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
imageAnalysis
|
||||
)
|
||||
camera?.let {
|
||||
onCameraReady(it.cameraControl, it.cameraInfo)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "CameraX bind failed", e)
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(context)
|
||||
)
|
||||
|
||||
onDispose {
|
||||
cameraProvider?.unbindAll()
|
||||
analysisExecutor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
surfaceRequest?.let { request ->
|
||||
val coordinateTransformer = remember { MutableCoordinateTransformer() }
|
||||
CameraXViewfinder(
|
||||
surfaceRequest = request,
|
||||
implementationMode = ImplementationMode.EXTERNAL,
|
||||
coordinateTransformer = coordinateTransformer,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processImageProxy(
|
||||
imageProxy: ImageProxy,
|
||||
foundResult: AtomicBoolean,
|
||||
onResult: (String) -> Unit
|
||||
) {
|
||||
if (foundResult.get()) {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val buffer = imageProxy.planes[0].buffer
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
val width = imageProxy.width
|
||||
val height = imageProxy.height
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
bytes, width, height,
|
||||
0, 0, width, height,
|
||||
false
|
||||
)
|
||||
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
|
||||
val hints = mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
DecodeHintType.CHARACTER_SET to "UTF-8"
|
||||
)
|
||||
val result = qrReader.decode(binaryBitmap, hints)
|
||||
val text = result.text
|
||||
if (!text.isNullOrEmpty() && foundResult.compareAndSet(false, true)) {
|
||||
onResult(text)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// do nothing
|
||||
} finally {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
@@ -1,770 +0,0 @@
|
||||
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.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_PORT
|
||||
import com.v2ray.ang.AppConfig.REALITY
|
||||
import com.v2ray.ang.AppConfig.TLS
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.CertificateFingerprintManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
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 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<out String> by lazy {
|
||||
resources.getStringArray(R.array.securitys)
|
||||
}
|
||||
private val shadowsocksSecuritys: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.ss_securitys)
|
||||
}
|
||||
private val flows: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.flows)
|
||||
}
|
||||
private val networks: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.networks)
|
||||
}
|
||||
private val tcpTypes: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.header_type_tcp)
|
||||
}
|
||||
private val kcpAndQuicTypes: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.header_type_kcp_and_quic)
|
||||
}
|
||||
private val grpcModes: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.mode_type_grpc)
|
||||
}
|
||||
private val streamSecuritys: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.streamsecurityxs)
|
||||
}
|
||||
private val allowinsecures: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.allowinsecures)
|
||||
}
|
||||
private val uTlsItems: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.streamsecurity_utls)
|
||||
}
|
||||
private val alpns: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.streamsecurity_alpn)
|
||||
}
|
||||
private val xhttpMode: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.xhttp_mode)
|
||||
}
|
||||
private val browserDialerModes: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.browser_dialer_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_mldsa65_verify: EditText? by lazy { findViewById(R.id.et_mldsa65_verify) }
|
||||
private val container_mldsa65_verify: LinearLayout? by lazy { findViewById(R.id.lay_mldsa65_verify) }
|
||||
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_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
|
||||
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
|
||||
private val et_kcp_mtu: EditText? by lazy { findViewById(R.id.et_kcp_mtu) }
|
||||
private val et_kcp_tti: EditText? by lazy { findViewById(R.id.et_kcp_tti) }
|
||||
private val layout_kcp: LinearLayout? by lazy { findViewById(R.id.layout_kcp) }
|
||||
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
|
||||
private val et_fm: EditText? by lazy { findViewById(R.id.et_fm) }
|
||||
private val layout_extra: LinearLayout? by lazy { findViewById(R.id.layout_extra) }
|
||||
private val et_ech_config_list: EditText? by lazy { findViewById(R.id.et_ech_config_list) }
|
||||
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
|
||||
private val et_verify_peer_cert_by_name: EditText? by lazy { findViewById(R.id.et_verify_peer_cert_by_name) }
|
||||
private val container_verify_peer_cert_by_name: LinearLayout? by lazy { findViewById(R.id.lay_verify_peer_cert_by_name) }
|
||||
private val et_pinned_ca256: EditText? by lazy { findViewById(R.id.et_pinned_ca256) }
|
||||
private val btn_pinned_ca256_action: Button? by lazy { findViewById(R.id.btn_pinned_ca256_action) }
|
||||
private val container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) }
|
||||
private val layout_browser_dialer: LinearLayout? by lazy { findViewById(R.id.layout_browser_dialer) }
|
||||
private val sp_browser_dialer_mode: Spinner? by lazy { findViewById(R.id.sp_browser_dialer_mode) }
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
|
||||
val layoutId = when (config?.configType ?: createConfigType) {
|
||||
EConfigType.VMESS -> R.layout.activity_server_vmess
|
||||
EConfigType.SHADOWSOCKS -> R.layout.activity_server_shadowsocks
|
||||
EConfigType.SOCKS, EConfigType.HTTP -> R.layout.activity_server_socks
|
||||
EConfigType.VLESS -> R.layout.activity_server_vless
|
||||
EConfigType.TROJAN -> R.layout.activity_server_trojan
|
||||
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
|
||||
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
|
||||
else -> null
|
||||
} ?: return
|
||||
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
|
||||
|
||||
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()
|
||||
)
|
||||
et_fm?.text = Utils.getEditable(config?.finalMask)
|
||||
|
||||
layout_kcp?.visibility =
|
||||
when (networks[position]) {
|
||||
NetworkType.KCP.type -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
et_kcp_mtu?.text = Utils.getEditable(config?.kcpMtu?.toString().orEmpty())
|
||||
et_kcp_tti?.text = Utils.getEditable(config?.kcpTti?.toString().orEmpty())
|
||||
|
||||
layout_extra?.visibility =
|
||||
when (networks[position]) {
|
||||
NetworkType.XHTTP.type -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
|
||||
layout_browser_dialer?.visibility =
|
||||
when (networks[position]) {
|
||||
NetworkType.WS.type -> View.VISIBLE
|
||||
NetworkType.XHTTP.type -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
// 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,
|
||||
container_mldsa65_verify,
|
||||
container_ech_config_list,
|
||||
container_verify_peer_cert_by_name,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.GONE }
|
||||
}
|
||||
|
||||
// Case 2: TLS value
|
||||
isTLS -> {
|
||||
listOf(
|
||||
container_sni,
|
||||
container_fingerprint,
|
||||
container_alpn,
|
||||
container_allow_insecure,
|
||||
container_ech_config_list,
|
||||
container_verify_peer_cert_by_name,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.VISIBLE }
|
||||
listOf(
|
||||
container_public_key,
|
||||
container_short_id,
|
||||
container_spider_x,
|
||||
container_mldsa65_verify
|
||||
).forEach { it?.visibility = View.GONE }
|
||||
}
|
||||
|
||||
// Case 3: Other reality values
|
||||
else -> {
|
||||
listOf(
|
||||
container_sni,
|
||||
container_fingerprint
|
||||
).forEach { it?.visibility = View.VISIBLE }
|
||||
listOf(
|
||||
container_alpn,
|
||||
container_allow_insecure,
|
||||
container_ech_config_list,
|
||||
container_verify_peer_cert_by_name,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.GONE }
|
||||
listOf(
|
||||
container_public_key,
|
||||
container_short_id,
|
||||
container_spider_x,
|
||||
container_mldsa65_verify
|
||||
).forEach { it?.visibility = View.VISIBLE }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(p0: AdapterView<*>?) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
btn_pinned_ca256_action?.setOnClickListener {
|
||||
fetchPinnedCA256ForCurrentConfig()
|
||||
}
|
||||
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_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)
|
||||
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) {
|
||||
val allowinsecure = Utils.arrayFind(allowinsecures, config.insecure.toString())
|
||||
if (allowinsecure >= 0) {
|
||||
sp_allow_insecure?.setSelection(allowinsecure)
|
||||
}
|
||||
et_ech_config_list?.text = Utils.getEditable(config.echConfigList)
|
||||
et_verify_peer_cert_by_name?.text = Utils.getEditable(config.verifyPeerCertByName)
|
||||
et_pinned_ca256?.text = Utils.getEditable(config.pinnedCA256)
|
||||
} else if (config.security == REALITY) {
|
||||
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
|
||||
et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
|
||||
et_spider_x?.text = Utils.getEditable(config.spiderX.orEmpty())
|
||||
et_mldsa65_verify?.text = Utils.getEditable(config.mldsa65Verify.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
val network = Utils.arrayFind(networks, config.network.orEmpty())
|
||||
if (network >= 0) {
|
||||
sp_network?.setSelection(network)
|
||||
}
|
||||
|
||||
val browserDialerMode = Utils.arrayFind(browserDialerModes, config.browserDialerMode.orEmpty())
|
||||
if (browserDialerMode >= 0) {
|
||||
sp_browser_dialer_mode?.setSelection(browserDialerMode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
sp_browser_dialer_mode?.setSelection(0)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if (et_fm?.text?.toString().isNotNullEmpty()) {
|
||||
if (JsonUtil.parseString(et_fm?.text?.toString()) == null) {
|
||||
toast(R.string.server_lab_final_mask)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
saveCommon(config)
|
||||
saveStreamSettings(config)
|
||||
saveTls(config)
|
||||
|
||||
config.description = AngConfigManager.generateDescription(config)
|
||||
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
//LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
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()?.trim()
|
||||
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().nullIfBlank()
|
||||
profileItem.finalMask = et_fm?.text?.toString()?.trim()?.nullIfBlank()
|
||||
profileItem.kcpMtu = et_kcp_mtu?.text?.toString()?.toIntOrNull()
|
||||
profileItem.kcpTti = et_kcp_tti?.text?.toString()?.toIntOrNull()
|
||||
if (networks[network] == NetworkType.WS.type || networks[network] == NetworkType.XHTTP.type) {
|
||||
val browserDialerMode = browserDialerModes[sp_browser_dialer_mode?.selectedItemPosition ?: 0]
|
||||
if (browserDialerMode != browserDialerModes[0]) {
|
||||
profileItem.browserDialerMode = browserDialerMode
|
||||
} else {
|
||||
profileItem.browserDialerMode = null
|
||||
}
|
||||
} else {
|
||||
profileItem.browserDialerMode = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveTls(config: ProfileItem) {
|
||||
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 mldsa65Verify = et_mldsa65_verify?.text?.toString()
|
||||
val echConfigList = et_ech_config_list?.text?.toString()
|
||||
val verifyPeerCertByName = et_verify_peer_cert_by_name?.text?.toString()
|
||||
val pinnedCA256 = et_pinned_ca256?.text?.toString()
|
||||
|
||||
val allowInsecure =
|
||||
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
|
||||
false
|
||||
} 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
|
||||
config.mldsa65Verify = mldsa65Verify
|
||||
config.echConfigList = echConfigList
|
||||
config.verifyPeerCertByName = verifyPeerCertByName
|
||||
config.pinnedCA256 = pinnedCA256
|
||||
}
|
||||
|
||||
private fun fetchPinnedCA256ForCurrentConfig() {
|
||||
val config = buildCurrentProfileForCertificateFetch() ?: return
|
||||
|
||||
lifecycleScope.launch {
|
||||
btn_pinned_ca256_action?.isEnabled = false
|
||||
try {
|
||||
val sha256 = withContext(Dispatchers.IO) {
|
||||
CertificateFingerprintManager.fetchForManualFill(config)
|
||||
}
|
||||
if (sha256.isNullOrBlank()) {
|
||||
toast(R.string.toast_fetch_cert_sha256_failed)
|
||||
} else {
|
||||
et_pinned_ca256?.text = Utils.getEditable(sha256)
|
||||
toastSuccess(R.string.toast_fetch_cert_sha256_success)
|
||||
}
|
||||
} finally {
|
||||
btn_pinned_ca256_action?.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCurrentProfileForCertificateFetch(): ProfileItem? {
|
||||
if (TextUtils.isEmpty(et_address.text.toString())) {
|
||||
toast(R.string.server_lab_address)
|
||||
return null
|
||||
}
|
||||
|
||||
val configType = MmkvManager.decodeServerConfig(editGuid)?.configType ?: createConfigType
|
||||
if (configType != EConfigType.HYSTERIA2 && Utils.parseInt(et_port.text.toString()) <= 0) {
|
||||
toast(R.string.server_lab_port)
|
||||
return null
|
||||
}
|
||||
|
||||
val config = ProfileItem.create(configType)
|
||||
saveCommon(config)
|
||||
saveStreamSettings(config)
|
||||
saveTls(config)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
private fun transportTypes(network: String?): Array<out String> {
|
||||
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)) {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
MmkvManager.removeServer(editGuid)
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
MmkvManager.removeServer(editGuid)
|
||||
finish()
|
||||
}
|
||||
} else {
|
||||
toast(R.string.toast_action_not_allowed)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.del_config -> {
|
||||
deleteServer()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save_config -> {
|
||||
saveServer()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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 com.blacksquircle.ui.editorkit.utils.EditorTheme
|
||||
import com.blacksquircle.ui.language.json.JsonLanguage
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class ServerCustomConfigActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityServerCustomConfigBinding.inflate(layoutInflater) }
|
||||
|
||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||
private val isRunning by lazy {
|
||||
intent.getBooleanExtra("isRunning", false)
|
||||
&& editGuid.isNotEmpty()
|
||||
&& editGuid == MmkvManager.getSelectServer()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.CUSTOM.toString())
|
||||
|
||||
if (!Utils.getDarkModeStatus(this)) {
|
||||
binding.editor.colorScheme = EditorTheme.INTELLIJ_LIGHT
|
||||
}
|
||||
binding.editor.language = JsonLanguage()
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
if (config != null) {
|
||||
bindingServer(config)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binding selected server config
|
||||
*/
|
||||
private fun bindingServer(config: ProfileItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
||||
val raw = MmkvManager.decodeServerRaw(editGuid)
|
||||
val configContent = raw.orEmpty()
|
||||
|
||||
binding.editor.setTextContent(Utils.getEditable(configContent))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* clear or init server config
|
||||
*/
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun saveServer(): Boolean {
|
||||
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
||||
toast(R.string.server_lab_remarks)
|
||||
return false
|
||||
}
|
||||
|
||||
val profileItem = try {
|
||||
CustomFmt.parse(binding.editor.text.toString())
|
||||
} catch (e: Exception) {
|
||||
LogUtil.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) ?: 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
|
||||
config.description = AngConfigManager.generateDescription(config)
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
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()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.del_config -> {
|
||||
deleteServer()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save_config -> {
|
||||
saveServer()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityServerGroupBinding
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class ServerGroupActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityServerGroupBinding.inflate(layoutInflater) }
|
||||
|
||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||
private val isRunning by lazy {
|
||||
intent.getBooleanExtra("isRunning", false)
|
||||
&& editGuid.isNotEmpty()
|
||||
&& editGuid == MmkvManager.getSelectServer()
|
||||
}
|
||||
private val subscriptionId by lazy {
|
||||
intent.getStringExtra("subscriptionId")
|
||||
}
|
||||
private val subIds = mutableListOf<String>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.POLICYGROUP.toString())
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
populateSubscriptionSpinner()
|
||||
|
||||
if (config != null) {
|
||||
bindingServer(config)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binding selected server config
|
||||
*/
|
||||
private fun bindingServer(config: ProfileItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
||||
binding.etPolicyGroupFilter.text = Utils.getEditable(config.policyGroupFilter)
|
||||
|
||||
val type = config.policyGroupType?.toInt() ?: 0
|
||||
binding.spPolicyGroupType.setSelection(type)
|
||||
|
||||
val pos = subIds.indexOf(config.policyGroupSubscriptionId ?: "").let { if (it >= 0) it else 0 }
|
||||
binding.spPolicyGroupSubId.setSelection(pos)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* clear or init server config
|
||||
*/
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
binding.etPolicyGroupFilter.text = null
|
||||
|
||||
if (subscriptionId.isNotNullEmpty()) {
|
||||
val pos = subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
|
||||
binding.spPolicyGroupSubId.setSelection(pos)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun saveServer(): Boolean {
|
||||
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
||||
toast(R.string.server_lab_remarks)
|
||||
return false
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.POLICYGROUP)
|
||||
config.remarks = binding.etRemarks.text.toString().trim()
|
||||
config.policyGroupFilter = binding.etPolicyGroupFilter.text.toString().trim()
|
||||
|
||||
config.policyGroupType = binding.spPolicyGroupType.selectedItemPosition.toString()
|
||||
|
||||
val selPos = binding.spPolicyGroupSubId.selectedItemPosition
|
||||
config.policyGroupSubscriptionId = if (selPos >= 0 && selPos < subIds.size) subIds[selPos] else null
|
||||
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
|
||||
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
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()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun populateSubscriptionSpinner() {
|
||||
val subs = MmkvManager.decodeSubscriptions()
|
||||
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
|
||||
subIds.clear()
|
||||
subIds.add("") // index 0 => All
|
||||
subs.forEach { sub ->
|
||||
val name = when {
|
||||
sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
|
||||
else -> sub.guid
|
||||
}
|
||||
displayList.add(name)
|
||||
subIds.add(sub.guid)
|
||||
}
|
||||
val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList)
|
||||
subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.spPolicyGroupSubId.adapter = subAdapter
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.del_config -> {
|
||||
deleteServer()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save_config -> {
|
||||
saveServer()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class ServerProxyChainActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityServerProxyChainBinding.inflate(layoutInflater) }
|
||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||
private val isRunning by lazy {
|
||||
intent.getBooleanExtra("isRunning", false)
|
||||
&& editGuid.isNotEmpty()
|
||||
&& editGuid == MmkvManager.getSelectServer()
|
||||
}
|
||||
private val subscriptionId by lazy {
|
||||
intent.getStringExtra("subscriptionId")
|
||||
}
|
||||
private lateinit var memberAdapter: ServerProxyChainMemberAdapter
|
||||
|
||||
private var allRemarks: List<String> = emptyList()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.PROXYCHAIN.toString())
|
||||
|
||||
loadAvailableRemarks()
|
||||
setupRecycler()
|
||||
binding.fabAddProxyChainMember.setOnClickListener {
|
||||
addMemberRow()
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
if (config != null) {
|
||||
bindingServer(config)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvailableRemarks() {
|
||||
allRemarks = SettingsManager.getProfileRemarks(
|
||||
excludeConfigTypes = setOf(
|
||||
EConfigType.CUSTOM,
|
||||
EConfigType.POLICYGROUP,
|
||||
EConfigType.PROXYCHAIN,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupRecycler() {
|
||||
memberAdapter = ServerProxyChainMemberAdapter(
|
||||
members = mutableListOf(),
|
||||
suggestions = allRemarks,
|
||||
adapterListener = ActivityAdapterListener()
|
||||
)
|
||||
binding.recyclerProxyChainMembers.layoutManager = LinearLayoutManager(this)
|
||||
binding.recyclerProxyChainMembers.adapter = memberAdapter
|
||||
ItemTouchHelper(SimpleItemTouchHelperCallback(memberAdapter)).attachToRecyclerView(binding.recyclerProxyChainMembers)
|
||||
}
|
||||
|
||||
private fun bindingServer(config: ProfileItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
||||
val rows = parseChainMembers(config.proxyChainProfiles)
|
||||
memberAdapter.replaceAll(rows)
|
||||
if (rows.isEmpty()) {
|
||||
memberAdapter.addRow()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
memberAdapter.replaceAll(listOf("", ""))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveServer(): Boolean {
|
||||
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
||||
toast(R.string.server_lab_remarks)
|
||||
return false
|
||||
}
|
||||
|
||||
val chainMembers = memberAdapter.getMembers().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
if (chainMembers.size != memberAdapter.getMembers().size) {
|
||||
toast(R.string.server_proxy_chain_members_unselected)
|
||||
return false
|
||||
}
|
||||
if (chainMembers.size < 2) {
|
||||
toast(R.string.server_proxy_chain_members_insufficient)
|
||||
return false
|
||||
}
|
||||
|
||||
val invalidMembers = chainMembers.filter { member ->
|
||||
val profile = SettingsManager.getServerViaRemarks(member)
|
||||
profile == null || profile.configType.isComplexType()
|
||||
}
|
||||
if (invalidMembers.isNotEmpty()) {
|
||||
toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", ")))
|
||||
return false
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.PROXYCHAIN)
|
||||
config.remarks = binding.etRemarks.text.toString().trim()
|
||||
config.proxyChainProfiles = chainMembers.joinToString(",")
|
||||
config.description = chainMembers.joinToString(" -> ")
|
||||
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteServer(): Boolean {
|
||||
if (editGuid.isNotEmpty()) {
|
||||
if (editGuid != MmkvManager.getSelectServer()) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
MmkvManager.removeServer(editGuid)
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
MmkvManager.removeServer(editGuid)
|
||||
finish()
|
||||
}
|
||||
} else {
|
||||
toast(R.string.toast_action_not_allowed)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun addMemberRow() {
|
||||
if (allRemarks.isEmpty()) {
|
||||
toast(R.string.toast_none_data)
|
||||
return
|
||||
}
|
||||
memberAdapter.addRow()
|
||||
}
|
||||
|
||||
private fun parseChainMembers(raw: String?): List<String> {
|
||||
return raw.orEmpty()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||
override fun onEdit(guid: String, position: Int) {
|
||||
// Row selection is handled directly by AutoCompleteTextView in the adapter.
|
||||
}
|
||||
|
||||
override fun onRemove(guid: String, position: Int) {
|
||||
memberAdapter.removeRow(position)
|
||||
}
|
||||
|
||||
override fun onShare(url: String) {
|
||||
}
|
||||
|
||||
override fun onRefreshData() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.del_config -> {
|
||||
deleteServer()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save_config -> {
|
||||
saveServer()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerProxyChainMemberBinding
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import java.util.Collections
|
||||
|
||||
class ServerProxyChainMemberAdapter(
|
||||
private val members: MutableList<String>,
|
||||
private val suggestions: List<String>,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<ServerProxyChainMemberAdapter.MemberViewHolder>(), ItemTouchHelperAdapter {
|
||||
|
||||
override fun getItemCount(): Int = members.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MemberViewHolder {
|
||||
return MemberViewHolder(
|
||||
ItemRecyclerProxyChainMemberBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: MemberViewHolder, position: Int) {
|
||||
val adapterPos = holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: position
|
||||
val value = members[position]
|
||||
holder.binding.tvMemberIndex.text = (position + 1).toString()
|
||||
|
||||
val dropdownAdapter = ArrayAdapter(
|
||||
holder.itemView.context,
|
||||
android.R.layout.simple_dropdown_item_1line,
|
||||
suggestions
|
||||
)
|
||||
holder.binding.spMemberRemark.setAdapter(dropdownAdapter)
|
||||
holder.binding.spMemberRemark.threshold = 0
|
||||
holder.binding.spMemberRemark.setText(value, false)
|
||||
|
||||
holder.binding.spMemberRemark.setOnItemClickListener { _, _, selectedIndex, _ ->
|
||||
if (adapterPos in members.indices) {
|
||||
members[adapterPos] = suggestions[selectedIndex].trim()
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
}
|
||||
holder.binding.spMemberRemark.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) return@OnFocusChangeListener
|
||||
val text = holder.binding.spMemberRemark.text?.toString().orEmpty().trim()
|
||||
if (adapterPos in members.indices && members[adapterPos] != text) {
|
||||
members[adapterPos] = text
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
}
|
||||
holder.binding.spMemberRemark.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
|
||||
holder.binding.btnMemberDropdown.setOnClickListener {
|
||||
holder.binding.spMemberRemark.requestFocus()
|
||||
holder.binding.spMemberRemark.showDropDown()
|
||||
}
|
||||
holder.itemView.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
|
||||
|
||||
holder.binding.layoutRemove.setOnClickListener {
|
||||
val removePos = holder.bindingAdapterPosition
|
||||
if (removePos != RecyclerView.NO_POSITION) {
|
||||
adapterListener?.onRemove("", removePos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRow() {
|
||||
members.add("")
|
||||
notifyItemInserted(members.lastIndex)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun removeRow(position: Int) {
|
||||
if (position < 0 || position >= members.size) return
|
||||
members.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
notifyItemRangeChanged(position, members.size - position)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun setRemark(position: Int, remark: String) {
|
||||
if (position < 0 || position >= members.size) return
|
||||
members[position] = remark
|
||||
notifyItemChanged(position)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun replaceAll(newMembers: List<String>) {
|
||||
members.clear()
|
||||
members.addAll(newMembers)
|
||||
notifyDataSetChanged()
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun getMembers(): List<String> = members.toList()
|
||||
|
||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
if (fromPosition == toPosition) return true
|
||||
Collections.swap(members, fromPosition, toPosition)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
notifyItemChanged(fromPosition)
|
||||
notifyItemChanged(toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
// Swipe-to-dismiss disabled for this adapter.
|
||||
}
|
||||
|
||||
class MemberViewHolder(val binding: ItemRecyclerProxyChainMemberBinding) :
|
||||
BaseViewHolder(binding.root), ItemTouchHelperViewHolder
|
||||
|
||||
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
fun onItemSelected() {
|
||||
itemView.setBackgroundColor(Color.LTGRAY)
|
||||
}
|
||||
|
||||
fun onItemClear() {
|
||||
itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.MmkvPreferenceDataStore
|
||||
import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings))
|
||||
}
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat() {
|
||||
|
||||
private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
|
||||
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
|
||||
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
|
||||
|
||||
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
|
||||
private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
|
||||
private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
|
||||
private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
|
||||
private val vpnMtu by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_MTU) }
|
||||
|
||||
private val mux by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_MUX_ENABLED) }
|
||||
private val muxConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_CONCURRENCY) }
|
||||
private val muxXudpConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_XUDP_CONCURRENCY) }
|
||||
private val muxXudpQuic by lazy { findPreference<ListPreference>(AppConfig.PREF_MUX_XUDP_QUIC) }
|
||||
|
||||
private val fragment by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FRAGMENT_ENABLED) }
|
||||
private val fragmentPackets by lazy { findPreference<ListPreference>(AppConfig.PREF_FRAGMENT_PACKETS) }
|
||||
private val fragmentLength by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_LENGTH) }
|
||||
private val fragmentInterval by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_INTERVAL) }
|
||||
private val fragmentMaxSplit by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_MAXSPLIT) }
|
||||
|
||||
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
|
||||
private val enableRootMode by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_MODE_ENABLE) }
|
||||
private val lanSharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_LAN_SHARING) }
|
||||
|
||||
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
|
||||
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
|
||||
private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
|
||||
|
||||
private val enableLocalProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ENABLE_LOCAL_PROXY) }
|
||||
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
|
||||
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
|
||||
private val socksUsername by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_USERNAME) }
|
||||
private val socksPassword by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PASSWORD) }
|
||||
private val socksEnableUdp by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_SOCKS_ENABLE_UDP) }
|
||||
private val proxySharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PROXY_SHARING) }
|
||||
|
||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||
// Use MMKV as the storage backend for all Preferences
|
||||
// This prevents inconsistencies between SharedPreferences and MMKV
|
||||
preferenceManager.preferenceDataStore = MmkvPreferenceDataStore()
|
||||
|
||||
addPreferencesFromResource(R.xml.pref_settings)
|
||||
|
||||
initPreferenceSummaries()
|
||||
|
||||
localDns?.setOnPreferenceChangeListener { _, any ->
|
||||
updateLocalDns(any as Boolean)
|
||||
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
|
||||
}
|
||||
|
||||
mode?.setOnPreferenceChangeListener { pref, newValue ->
|
||||
val valueStr = newValue.toString()
|
||||
(pref as? ListPreference)?.let { lp ->
|
||||
val idx = lp.findIndexOfValue(valueStr)
|
||||
lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
|
||||
}
|
||||
updateMode(valueStr)
|
||||
true
|
||||
}
|
||||
|
||||
mode?.dialogLayoutResource = R.layout.preference_with_help_link
|
||||
|
||||
useHevTun?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateHevTunSettings(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
|
||||
enableLocalProxy?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateEnableLocalProxy(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
|
||||
dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateDynamicSocksPort(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
|
||||
enableRootMode?.setOnPreferenceChangeListener { _, newValue ->
|
||||
if (newValue == true && !RootManager.cachedRoot()) {
|
||||
lifecycleScope.launch {
|
||||
if (checkAndRequestRoot()) {
|
||||
enableRootMode?.isChecked = true
|
||||
}
|
||||
}
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
lanSharing?.setOnPreferenceChangeListener { _, newValue ->
|
||||
if (newValue == true && !RootManager.cachedRoot()) {
|
||||
lifecycleScope.launch {
|
||||
if (checkAndRequestRoot()) {
|
||||
lanSharing?.isChecked = true
|
||||
}
|
||||
}
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun initPreferenceSummaries() {
|
||||
fun updateSummary(pref: androidx.preference.Preference) {
|
||||
when (pref) {
|
||||
is EditTextPreference -> {
|
||||
if (pref.key == AppConfig.PREF_SOCKS_PASSWORD) {
|
||||
pref.summary = if (pref.text.isNullOrEmpty()) "" else "******"
|
||||
} else {
|
||||
pref.summary = pref.text.orEmpty()
|
||||
}
|
||||
pref.setOnPreferenceChangeListener { p, newValue ->
|
||||
if (p.key == AppConfig.PREF_SOCKS_PASSWORD) {
|
||||
p.summary = if ((newValue as? String).isNullOrEmpty()) "" else "******"
|
||||
} else {
|
||||
p.summary = (newValue as? String).orEmpty()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
is ListPreference -> {
|
||||
pref.summary = pref.entry ?: ""
|
||||
pref.setOnPreferenceChangeListener { p, newValue ->
|
||||
val lp = p as ListPreference
|
||||
val idx = lp.findIndexOfValue(newValue as? String)
|
||||
lp.summary = (if (idx >= 0) lp.entries[idx] else newValue) as CharSequence?
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun traverse(group: androidx.preference.PreferenceGroup) {
|
||||
for (i in 0 until group.preferenceCount) {
|
||||
when (val p = group.getPreference(i)) {
|
||||
is androidx.preference.PreferenceGroup -> traverse(p)
|
||||
else -> updateSummary(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preferenceScreen?.let { traverse(it) }
|
||||
}
|
||||
|
||||
private suspend fun checkAndRequestRoot(): Boolean {
|
||||
val hasRoot = RootManager.refresh()
|
||||
if (!isAdded) return false
|
||||
if (!hasRoot) {
|
||||
context?.toastError(R.string.toast_root_required)
|
||||
}
|
||||
return hasRoot
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
|
||||
|
||||
// Initialize mode-dependent UI states
|
||||
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
|
||||
|
||||
// Initialize local proxy state
|
||||
updateEnableLocalProxy(MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true))
|
||||
|
||||
// Initialize mux-dependent UI states
|
||||
updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false))
|
||||
|
||||
// Initialize fragment-dependent UI states
|
||||
updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false))
|
||||
|
||||
updateDynamicSocksPort(MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false))
|
||||
}
|
||||
|
||||
private fun updateMode(value: String?) {
|
||||
val vpn = value == VPN
|
||||
localDns?.isEnabled = vpn
|
||||
fakeDns?.isEnabled = vpn
|
||||
appendHttpProxy?.isEnabled = vpn
|
||||
// localDnsPort?.isEnabled = vpn
|
||||
vpnDns?.isEnabled = vpn
|
||||
vpnBypassLan?.isEnabled = vpn
|
||||
vpnInterfaceAddress?.isEnabled = vpn
|
||||
vpnMtu?.isEnabled = vpn
|
||||
useHevTun?.isEnabled = vpn
|
||||
updateHevTunSettings(false)
|
||||
if (vpn) {
|
||||
updateLocalDns(
|
||||
MmkvManager.decodeSettingsBool(
|
||||
AppConfig.PREF_LOCAL_DNS_ENABLED,
|
||||
false
|
||||
)
|
||||
)
|
||||
updateHevTunSettings(
|
||||
MmkvManager.decodeSettingsBool(
|
||||
AppConfig.PREF_USE_HEV_TUNNEL,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateLocalDns(enabled: Boolean) {
|
||||
fakeDns?.isEnabled = enabled
|
||||
// localDnsPort?.isEnabled = enabled
|
||||
vpnDns?.isEnabled = !enabled
|
||||
}
|
||||
|
||||
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
|
||||
fragmentMaxSplit?.isEnabled = enabled
|
||||
}
|
||||
|
||||
private fun updateDynamicSocksPort(enabled: Boolean) {
|
||||
socksPort?.isEnabled = (enableLocalProxy?.isChecked == true) && !enabled
|
||||
}
|
||||
|
||||
private fun updateEnableLocalProxy(enabled: Boolean) {
|
||||
val dynamic = MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
|
||||
socksPort?.isEnabled = enabled && !dynamic
|
||||
dynamicSocksPort?.isEnabled = enabled
|
||||
socksUsername?.isEnabled = enabled
|
||||
socksPassword?.isEnabled = enabled
|
||||
socksEnableUdp?.isEnabled = enabled
|
||||
proxySharing?.isEnabled = enabled
|
||||
|
||||
if (!enabled) {
|
||||
if (appendHttpProxy?.isChecked == true) {
|
||||
appendHttpProxy?.isChecked = false
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_APPEND_HTTP_PROXY, false)
|
||||
}
|
||||
appendHttpProxy?.isEnabled = false
|
||||
} else {
|
||||
val vpn = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) == VPN
|
||||
appendHttpProxy?.isEnabled = vpn
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHevTunSettings(enabled: Boolean) {
|
||||
hevTunLogLevel?.isEnabled = enabled
|
||||
hevTunRwTimeout?.isEnabled = enabled
|
||||
|
||||
if (enabled) {
|
||||
if (enableLocalProxy?.isChecked == false) {
|
||||
enableLocalProxy?.isChecked = true
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
|
||||
}
|
||||
enableLocalProxy?.isEnabled = false
|
||||
} else {
|
||||
enableLocalProxy?.isEnabled = true
|
||||
}
|
||||
updateEnableLocalProxy(enableLocalProxy?.isChecked == true)
|
||||
}
|
||||
}
|
||||
|
||||
fun onModeHelpClicked(view: View) {
|
||||
Utils.openUri(this, AppConfig.APP_WIKI_MODE)
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.AutoCompleteTextView
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivitySubEditBinding
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
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)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
||||
|
||||
setupProfileRemarkInputs()
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
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.etUserAgent.text = Utils.getEditable(subItem.userAgent)
|
||||
binding.etFilter.text = Utils.getEditable(subItem.filter)
|
||||
binding.chkEnable.isChecked = subItem.enabled
|
||||
binding.autoUpdateCheck.isChecked = subItem.autoUpdate
|
||||
binding.etUpdateInterval.text = Utils.getEditable(subItem.updateInterval.toString())
|
||||
binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
|
||||
binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
|
||||
binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
|
||||
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.etUpdateInterval.text = null
|
||||
binding.etPreProfile.text = null
|
||||
binding.etNextProfile.text = null
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setupProfileRemarkInputs() {
|
||||
val suggestions = SettingsManager.getProfileRemarks(
|
||||
excludeConfigTypes = setOf(
|
||||
EConfigType.CUSTOM,
|
||||
EConfigType.POLICYGROUP,
|
||||
EConfigType.PROXYCHAIN,
|
||||
)
|
||||
)
|
||||
|
||||
setupProfileRemarkInput(binding.etPreProfile, binding.btnPreProfileDropdown, suggestions)
|
||||
setupProfileRemarkInput(binding.etNextProfile, binding.btnNextProfileDropdown, suggestions)
|
||||
}
|
||||
|
||||
private fun setupProfileRemarkInput(
|
||||
input: AutoCompleteTextView,
|
||||
dropdownButton: ImageButton,
|
||||
suggestions: List<String>
|
||||
) {
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
|
||||
input.setAdapter(adapter)
|
||||
input.threshold = 0
|
||||
|
||||
dropdownButton.setOnClickListener {
|
||||
input.requestFocus()
|
||||
input.showDropDown()
|
||||
}
|
||||
input.setOnClickListener {
|
||||
input.showDropDown()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun saveServer(): Boolean {
|
||||
val subItem = MmkvManager.decodeSubscription(editSubId) ?: SubscriptionItem()
|
||||
|
||||
subItem.remarks = binding.etRemarks.text.toString()
|
||||
subItem.url = binding.etUrl.text.toString()
|
||||
subItem.userAgent = binding.etUserAgent.text.toString()
|
||||
subItem.filter = binding.etFilter.text.toString()
|
||||
subItem.enabled = binding.chkEnable.isChecked
|
||||
subItem.autoUpdate = binding.autoUpdateCheck.isChecked
|
||||
|
||||
val intervalInput = binding.etUpdateInterval.text.toString().trim()
|
||||
val intervalMinutes = intervalInput.toLongOrNull()
|
||||
if (subItem.autoUpdate) {
|
||||
// autoUpdate is enabled: interval must be valid
|
||||
if (intervalMinutes == null) {
|
||||
// field is empty, reset to default
|
||||
subItem.updateInterval = SubscriptionItem().updateInterval
|
||||
} else if (intervalMinutes < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
|
||||
toast(R.string.toast_invalid_update_interval)
|
||||
return false
|
||||
} else {
|
||||
subItem.updateInterval = intervalMinutes
|
||||
}
|
||||
} else {
|
||||
// autoUpdate is disabled: save only if the value is valid, otherwise keep the existing value
|
||||
if (intervalMinutes != null && intervalMinutes >= AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
|
||||
subItem.updateInterval = intervalMinutes
|
||||
}
|
||||
}
|
||||
|
||||
subItem.prevProfile = binding.etPreProfile.text.toString()
|
||||
subItem.nextProfile = binding.etNextProfile.text.toString()
|
||||
subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
|
||||
|
||||
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)
|
||||
SubscriptionUpdater.syncOne(subId = editSubId)
|
||||
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)) {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||
launch(Dispatchers.Main) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
SettingsManager.removeSubscriptionWithDefault(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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
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.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivitySubSettingBinding
|
||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SubSettingActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
|
||||
private val ownerActivity: SubSettingActivity
|
||||
get() = this
|
||||
private val viewModel: SubscriptionsViewModel by viewModels()
|
||||
private lateinit var adapter: SubSettingRecyclerAdapter
|
||||
private var mItemTouchHelper: ItemTouchHelper? = null
|
||||
private val share_method: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.share_sub_method)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
||||
|
||||
adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
|
||||
|
||||
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 -> {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = AngConfigManager.updateConfigViaSubAll()
|
||||
delay(500L)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||
toast(R.string.title_update_subscription_no_subscription)
|
||||
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||
} else {
|
||||
toast(
|
||||
getString(
|
||||
R.string.title_update_subscription_result,
|
||||
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||
)
|
||||
)
|
||||
}
|
||||
hideLoading()
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refreshData() {
|
||||
viewModel.reload()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||
override fun onEdit(guid: String, position: Int) {
|
||||
startActivity(
|
||||
Intent(ownerActivity, SubEditActivity::class.java)
|
||||
.putExtra("subId", guid)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRemove(guid: String, position: Int) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||
AlertDialog.Builder(ownerActivity)
|
||||
.setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
viewModel.remove(guid)
|
||||
refreshData()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
} else {
|
||||
viewModel.remove(guid)
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShare(url: String) {
|
||||
AlertDialog.Builder(ownerActivity)
|
||||
.setItems(share_method.asList().toTypedArray()) { _, i ->
|
||||
try {
|
||||
when (i) {
|
||||
0 -> {
|
||||
val ivBinding =
|
||||
ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
|
||||
ivBinding.ivQcode.setImageBitmap(
|
||||
QRCodeDecoder.createQRCode(
|
||||
url
|
||||
|
||||
)
|
||||
)
|
||||
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
|
||||
}
|
||||
|
||||
1 -> {
|
||||
Utils.setClipboard(ownerActivity, url)
|
||||
}
|
||||
|
||||
else -> ownerActivity.toast("else")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Share subscription failed", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
override fun onRefreshData() {
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.text.TextUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||
|
||||
class SubSettingRecyclerAdapter(
|
||||
private val viewModel: SubscriptionsViewModel,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
|
||||
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
val subscriptions = viewModel.getAll()
|
||||
val subId = subscriptions[position].guid
|
||||
val subItem = subscriptions[position].subscription
|
||||
holder.itemSubSettingBinding.tvName.text = subItem.remarks
|
||||
holder.itemSubSettingBinding.tvUrl.text = subItem.url
|
||||
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
|
||||
holder.itemSubSettingBinding.tvLastUpdated.text = Utils.formatTimestamp(subItem.lastUpdated)
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
|
||||
adapterListener?.onEdit(subId, position)
|
||||
}
|
||||
|
||||
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
|
||||
adapterListener?.onRemove(subId, position)
|
||||
}
|
||||
|
||||
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||
if (!it.isPressed) return@setOnCheckedChangeListener
|
||||
subItem.enabled = isChecked
|
||||
viewModel.update(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
|
||||
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.INVISIBLE
|
||||
} else {
|
||||
holder.itemSubSettingBinding.layoutUrl.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
|
||||
adapterListener?.onShare(subItem.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
viewModel.swap(fromPosition, toPosition)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ListView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityTaskerBinding
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
class TaskerActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) }
|
||||
|
||||
private var listview: ListView? = null
|
||||
private var lstData: ArrayList<String> = ArrayList()
|
||||
private var lstGuid: ArrayList<String> = ArrayList()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "")
|
||||
|
||||
//add def value
|
||||
lstData.add("Default")
|
||||
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
||||
|
||||
MmkvManager.decodeAllServerList().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
|
||||
)
|
||||
listview = findViewById<View>(R.id.listview) as ListView
|
||||
listview?.adapter = adapter
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
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, "")
|
||||
|
||||
if (switch == null || TextUtils.isEmpty(guid)) {
|
||||
return
|
||||
} else {
|
||||
binding.switchStartService.isChecked = switch
|
||||
val pos = lstGuid.indexOf(guid.toString())
|
||||
if (pos >= 0) {
|
||||
listview?.setItemChecked(pos, true)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmFinish() {
|
||||
val position = listview?.checkedItemPosition
|
||||
if (position == null || position < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val extraBundle = Bundle()
|
||||
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, binding.switchStartService.isChecked)
|
||||
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
|
||||
val intent = Intent()
|
||||
|
||||
val remarks = lstData[position]
|
||||
val blurb = if (binding.switchStartService.isChecked) {
|
||||
"Start $remarks"
|
||||
} else {
|
||||
"Stop $remarks"
|
||||
}
|
||||
|
||||
intent.putExtra(AppConfig.TASKER_EXTRA_BUNDLE, extraBundle)
|
||||
intent.putExtra(AppConfig.TASKER_EXTRA_STRING_BLURB, blurb)
|
||||
setResult(RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
val delConfig = menu.findItem(R.id.del_config)
|
||||
delConfig?.isVisible = false
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.del_config -> {
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save_config -> {
|
||||
confirmFinish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,26 +3,25 @@ package com.v2ray.ang.ui
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
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 com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.main.MainActivity
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
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) }
|
||||
class UrlSchemeActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
try {
|
||||
intent.apply {
|
||||
if (action == Intent.ACTION_SEND) {
|
||||
@@ -59,6 +58,10 @@ class UrlSchemeActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
}
|
||||
|
||||
private fun parseUri(uriString: String?, fragment: String?) {
|
||||
if (uriString.isNullOrEmpty()) {
|
||||
return
|
||||
@@ -84,4 +87,4 @@ class UrlSchemeActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivityUserAssetBinding
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
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.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class UserAssetActivity : HelperBaseActivity() {
|
||||
private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) }
|
||||
private val ownerActivity: UserAssetActivity
|
||||
get() = this
|
||||
private val viewModel: UserAssetViewModel by viewModels()
|
||||
private lateinit var adapter: UserAssetAdapter
|
||||
|
||||
val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, 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)
|
||||
adapter = UserAssetAdapter(viewModel, extDir, ActivityAdapterListener())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
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) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to set geo files sources", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun showFileChooser() {
|
||||
launchFileChooser { uri ->
|
||||
if (uri == null) {
|
||||
return@launchFileChooser
|
||||
}
|
||||
|
||||
val assetId = Utils.getUuid()
|
||||
runCatching {
|
||||
val assetItem = AssetUrlItem(
|
||||
getCursorName(uri) ?: uri.toString(),
|
||||
"file"
|
||||
)
|
||||
|
||||
val assetList = MmkvManager.decodeAssetUrls()
|
||||
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != 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) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to get cursor name", e)
|
||||
null
|
||||
}
|
||||
|
||||
private fun importAssetFromQRcode(): Boolean {
|
||||
launchQRCodeScanner { scanResult ->
|
||||
if (scanResult != null) {
|
||||
importAsset(scanResult)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to import asset from URL", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun downloadGeoFiles() {
|
||||
refreshData()
|
||||
showLoading()
|
||||
toast(R.string.msg_downloading_content)
|
||||
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.successCount))
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
refreshData()
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun initAssets() {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
SettingsManager.initAssets(this@UserAssetActivity, assets)
|
||||
withContext(Dispatchers.Main) {
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refreshData() {
|
||||
viewModel.reload(getGeoFilesSources())
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||
override fun onEdit(guid: String, position: Int) {
|
||||
startActivity(
|
||||
Intent(ownerActivity, UserAssetUrlActivity::class.java)
|
||||
.putExtra("assetId", guid)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onRemove(guid: String, position: Int) {
|
||||
val asset = viewModel.getAsset(position)?.takeIf { it.guid == guid }
|
||||
?: viewModel.getAssets().find { it.guid == guid }
|
||||
?: return
|
||||
val file = extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }
|
||||
|
||||
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
file?.delete()
|
||||
MmkvManager.removeAssetUrl(guid)
|
||||
initAssets()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onShare(url: String) {
|
||||
}
|
||||
|
||||
override fun onRefreshData() {
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user