Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3118b53f2 | |||
| cd45b51487 | |||
| 6aa26d2621 | |||
| 0b452707c3 | |||
| 303b642ade | |||
| 80eec036dd | |||
| f9a85366ea | |||
| 378c891399 | |||
| d04b5eee37 | |||
| 405cd7f55e | |||
| 850096789b | |||
| 3d42ac9dba | |||
| d0265265f3 | |||
| 2d6dd33a7b | |||
| 3a32373abe | |||
| befb2937d1 | |||
| 0a375bec67 | |||
| 029f8b67e7 | |||
| 7475d1757b | |||
| 5a16614a7b | |||
| edb06b466a | |||
| 30b5391895 | |||
| 6cd04b831f | |||
| 1e616198f0 | |||
| a710146e4f | |||
| 9cd530d223 | |||
| fc89f25591 | |||
| 84ab54fea2 | |||
| d642d09844 | |||
| 6ae46c50e2 | |||
| fe4b451988 | |||
| 5c9b634039 | |||
| 09461663cb | |||
| 50c1145815 | |||
| edc4856cb8 | |||
| 4dd7eee8fe | |||
| 516637954c | |||
| 9e714bfcb3 | |||
| 858dc5237a | |||
| 2b4a4f7ecc | |||
| f9f0c905a4 | |||
| f997335113 | |||
| ed344cd281 | |||
| 94775f120a | |||
| abd23b3227 | |||
| 7d3e002140 | |||
| 1adb22944a | |||
| f48253371d | |||
| 74f701096e | |||
| 8eca5fe8af | |||
| ff40b53e2f | |||
| 5080d94bb7 | |||
| 24539be29f | |||
| e951e3d913 | |||
| 83e5f1d10a | |||
| 47a6623e62 | |||
| d6e3fc5c91 | |||
| 685d23f2d1 | |||
| d46bbdc219 | |||
| be95e6a88e | |||
| 2435708628 | |||
| 6cf6057dfa | |||
| af92cf5a7d | |||
| 2277de95c0 | |||
| 3c90ee0206 | |||
| 41fd237a87 | |||
| f6bded88dd | |||
| 8600fdba35 | |||
| 2c47abc219 | |||
| 10b0f8c9de | |||
| 53572877fa | |||
| 69e000b23d | |||
| a8e09611eb | |||
| 4fdc46b8ca | |||
| ac043da8a2 | |||
| a192fec127 | |||
| b0b09273cf | |||
| 01a943b7ea | |||
| 06a3738b59 | |||
| fa4432584e | |||
| bb1c3d915f | |||
| d8983a4761 | |||
| 9815041c77 | |||
| 41b0983582 | |||
| bb1f677d45 | |||
| de1c8bcfcc | |||
| ca6e963575 | |||
| bfb6813dc7 | |||
| cc2b4eec65 | |||
| dd11569969 | |||
| 11c3dd97c3 | |||
| 92f15150cd | |||
| f8e809da8b | |||
| 12b9349e62 | |||
| a580494bde |
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
fetch-depth: '0'
|
fetch-depth: '0'
|
||||||
@@ -25,8 +25,8 @@ jobs:
|
|||||||
uses: android-actions/setup-android@v3.2.0
|
uses: android-actions/setup-android@v3.2.0
|
||||||
with:
|
with:
|
||||||
log-accepted-android-sdk-licenses: false
|
log-accepted-android-sdk-licenses: false
|
||||||
cmdline-tools-version: '12266719'
|
cmdline-tools-version: '13114758'
|
||||||
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools'
|
packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
|
||||||
|
|
||||||
- name: Install NDK
|
- name: Install NDK
|
||||||
run: |
|
run: |
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
out-file-path: V2rayNG/app/libs/
|
out-file-path: V2rayNG/app/libs/
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4.7.0
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
@@ -99,21 +99,21 @@ jobs:
|
|||||||
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
|
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Upload arm64-v8a APK
|
- name: Upload arm64-v8a APK
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: arm64-v8a
|
name: arm64-v8a
|
||||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
||||||
|
|
||||||
- name: Upload armeabi-v7a APK
|
- name: Upload armeabi-v7a APK
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: armeabi-v7a
|
name: armeabi-v7a
|
||||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
||||||
|
|
||||||
- name: Upload x86 APK
|
- name: Upload x86 APK
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: x86-apk
|
name: x86-apk
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ jobs:
|
|||||||
go:
|
go:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Validate Fastlane Supply Metadata
|
- name: Validate Fastlane Supply Metadata
|
||||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
||||||
|
|||||||
+1
-1
Submodule AndroidLibXrayLite updated: 16222c3387...c0a0231614
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "com.v2ray.ang"
|
applicationId = "com.v2ray.ang"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 702
|
versionCode = 715
|
||||||
versionName = "2.0.2"
|
versionName = "2.0.15"
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||||
|
|||||||
@@ -42,6 +42,13 @@
|
|||||||
"name": "xray0",
|
"name": "xray0",
|
||||||
"MTU": 1500,
|
"MTU": 1500,
|
||||||
"userLevel": 8
|
"userLevel": 8
|
||||||
|
},
|
||||||
|
"sniffing": {
|
||||||
|
"enabled": true,
|
||||||
|
"destOverride": [
|
||||||
|
"http",
|
||||||
|
"tls"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -34,16 +34,15 @@ class AngApplication : MultiDexApplication() {
|
|||||||
|
|
||||||
MMKV.initialize(this)
|
MMKV.initialize(this)
|
||||||
|
|
||||||
// Ensure critical preference defaults are present in MMKV early
|
|
||||||
SettingsManager.ensureDefaultSettings()
|
|
||||||
SettingsManager.setNightMode()
|
|
||||||
// Initialize WorkManager with the custom configuration
|
// Initialize WorkManager with the custom configuration
|
||||||
WorkManager.initialize(this, workManagerConfiguration)
|
WorkManager.initialize(this, workManagerConfiguration)
|
||||||
|
|
||||||
SettingsManager.initRoutingRulesets(this)
|
// Ensure critical preference defaults are present in MMKV early
|
||||||
|
SettingsManager.initApp(this)
|
||||||
|
SettingsManager.setNightMode()
|
||||||
|
|
||||||
es.dmoral.toasty.Toasty.Config.getInstance()
|
es.dmoral.toasty.Toasty.Config.getInstance()
|
||||||
.setGravity(android.view.Gravity.BOTTOM, 0, 200)
|
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ object AppConfig {
|
|||||||
/** Legacy configuration keys. */
|
/** Legacy configuration keys. */
|
||||||
const val ANG_CONFIG = "ang_config"
|
const val ANG_CONFIG = "ang_config"
|
||||||
|
|
||||||
|
// Default subscription ID for ungrouped servers
|
||||||
|
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
|
||||||
|
|
||||||
/** Preferences mapped to MMKV storage. */
|
/** Preferences mapped to MMKV storage. */
|
||||||
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
||||||
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
||||||
@@ -48,6 +51,7 @@ object AppConfig {
|
|||||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||||
|
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||||
const val PREF_LANGUAGE = "pref_language"
|
const val PREF_LANGUAGE = "pref_language"
|
||||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
||||||
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||||
@@ -73,7 +77,6 @@ object AppConfig {
|
|||||||
|
|
||||||
/** Cache keys. */
|
/** Cache keys. */
|
||||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||||
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
|
|
||||||
|
|
||||||
/** Protocol identifiers. */
|
/** Protocol identifiers. */
|
||||||
const val PROTOCOL_FREEDOM = "freedom"
|
const val PROTOCOL_FREEDOM = "freedom"
|
||||||
@@ -97,6 +100,7 @@ object AppConfig {
|
|||||||
const val TAG_FRAGMENT = "fragment"
|
const val TAG_FRAGMENT = "fragment"
|
||||||
const val TAG_DNS = "dns-module"
|
const val TAG_DNS = "dns-module"
|
||||||
const val TAG_DOMESTIC_DNS = "domestic-dns"
|
const val TAG_DOMESTIC_DNS = "domestic-dns"
|
||||||
|
const val TAG_BALANCER = "proxy-round"
|
||||||
|
|
||||||
/** Network-related constants. */
|
/** Network-related constants. */
|
||||||
const val UPLINK = "uplink"
|
const val UPLINK = "uplink"
|
||||||
@@ -128,6 +132,12 @@ object AppConfig {
|
|||||||
const val GEOIP_PRIVATE = "geoip:private"
|
const val GEOIP_PRIVATE = "geoip:private"
|
||||||
const val GEOIP_CN = "geoip:cn"
|
const val GEOIP_CN = "geoip:cn"
|
||||||
|
|
||||||
|
/** Geo data file names. */
|
||||||
|
const val GEOSITE_DAT = "geosite.dat"
|
||||||
|
const val GEOIP_DAT = "geoip.dat"
|
||||||
|
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
|
||||||
|
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
|
||||||
|
|
||||||
/** Ports and addresses for various services. */
|
/** Ports and addresses for various services. */
|
||||||
const val PORT_LOCAL_DNS = "10853"
|
const val PORT_LOCAL_DNS = "10853"
|
||||||
const val PORT_SOCKS = "10808"
|
const val PORT_SOCKS = "10808"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.v2ray.ang.contracts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
|
||||||
|
* Extend this interface or define more specific interfaces for different adapters as needed.
|
||||||
|
*/
|
||||||
|
interface BaseAdapterListener {
|
||||||
|
/**
|
||||||
|
* Request the host to edit the specified item.
|
||||||
|
* @param guid Unique identifier (GUID) of the item
|
||||||
|
* @param position Current position in the adapter (optional; host should validate it)
|
||||||
|
*/
|
||||||
|
fun onEdit(guid: String, position: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the host to remove the specified item. Position is provided for optional animation or validation.
|
||||||
|
* @param guid Unique identifier (GUID) of the item
|
||||||
|
* @param position Current position in the adapter (optional; host should validate it)
|
||||||
|
*/
|
||||||
|
fun onRemove(guid: String, position: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the host to share the specified URL.
|
||||||
|
* @param url The URL to be shared
|
||||||
|
*/
|
||||||
|
fun onShare(url: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
|
||||||
|
*/
|
||||||
|
fun onRefreshData()
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.v2ray.ang.contracts
|
||||||
|
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
|
||||||
|
interface MainAdapterListener :BaseAdapterListener {
|
||||||
|
|
||||||
|
fun onEdit(guid: String, position: Int, profile: ProfileItem)
|
||||||
|
|
||||||
|
fun onSelectServer(guid: String)
|
||||||
|
|
||||||
|
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
|
||||||
|
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.service
|
package com.v2ray.ang.contracts
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.service
|
package com.v2ray.ang.contracts
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface that defines the control operations for tun2socks implementations.
|
* Interface that defines the control operations for tun2socks implementations.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
data class AssetUrlCache(
|
||||||
|
val guid: String,
|
||||||
|
val assetUrl: AssetUrlItem
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig.PORT_SOCKS
|
|||||||
import com.v2ray.ang.AppConfig.TAG_BLOCKED
|
import com.v2ray.ang.AppConfig.TAG_BLOCKED
|
||||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
data class ProfileItem(
|
data class ProfileItem(
|
||||||
@@ -14,6 +15,7 @@ data class ProfileItem(
|
|||||||
var addedTime: Long = System.currentTimeMillis(),
|
var addedTime: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
var remarks: String = "",
|
var remarks: String = "",
|
||||||
|
var description: String? = null,
|
||||||
var server: String? = null,
|
var server: String? = null,
|
||||||
var serverPort: String? = null,
|
var serverPort: String? = null,
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ data class ProfileItem(
|
|||||||
var insecure: Boolean? = null,
|
var insecure: Boolean? = null,
|
||||||
var echConfigList: String? = null,
|
var echConfigList: String? = null,
|
||||||
var echForceQuery: String? = null,
|
var echForceQuery: String? = null,
|
||||||
|
var pinnedCA256: String? = null,
|
||||||
|
|
||||||
var publicKey: String? = null,
|
var publicKey: String? = null,
|
||||||
var shortId: String? = null,
|
var shortId: String? = null,
|
||||||
@@ -121,7 +124,7 @@ data class ProfileItem(
|
|||||||
&& this.obfsPassword == obj.obfsPassword
|
&& this.obfsPassword == obj.obfsPassword
|
||||||
&& this.portHopping == obj.portHopping
|
&& this.portHopping == obj.portHopping
|
||||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||||
&& this.pinSHA256 == obj.pinSHA256
|
&& this.pinnedCA256 == obj.pinnedCA256
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
data class SubscriptionCache(
|
||||||
|
val guid: String,
|
||||||
|
val subscription: SubscriptionItem
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of subscription update operation
|
||||||
|
*/
|
||||||
|
data class SubscriptionUpdateResult(
|
||||||
|
val configCount: Int = 0, // Total configs updated
|
||||||
|
val successCount: Int = 0, // Subscriptions updated successfully
|
||||||
|
val failureCount: Int = 0, // Subscriptions failed to update
|
||||||
|
val skipCount: Int = 0 // Subscriptions skipped (disabled)
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Combine two results by adding their counts
|
||||||
|
*/
|
||||||
|
operator fun plus(other: SubscriptionUpdateResult): SubscriptionUpdateResult {
|
||||||
|
return SubscriptionUpdateResult(
|
||||||
|
configCount = this.configCount + other.configCount,
|
||||||
|
successCount = this.successCount + other.successCount,
|
||||||
|
failureCount = this.failureCount + other.failureCount,
|
||||||
|
skipCount = this.skipCount + other.skipCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
data class TestServiceMessage(
|
||||||
|
val key: Int,
|
||||||
|
val subscriptionId: String = "",
|
||||||
|
val serverGuids: List<String> = emptyList()
|
||||||
|
) : Serializable
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ package com.v2ray.ang.dto
|
|||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
|
||||||
data class V2rayConfig(
|
data class V2rayConfig(
|
||||||
var remarks: String? = null,
|
var remarks: String? = null,
|
||||||
@@ -34,7 +35,7 @@ data class V2rayConfig(
|
|||||||
var protocol: String,
|
var protocol: String,
|
||||||
var listen: String? = null,
|
var listen: String? = null,
|
||||||
var settings: InSettingsBean? = null,
|
var settings: InSettingsBean? = null,
|
||||||
val sniffing: SniffingBean? = null,
|
var sniffing: SniffingBean? = null,
|
||||||
val streamSettings: Any? = null,
|
val streamSettings: Any? = null,
|
||||||
val allocate: Any? = null
|
val allocate: Any? = null
|
||||||
) {
|
) {
|
||||||
@@ -88,6 +89,7 @@ data class V2rayConfig(
|
|||||||
var reserved: List<Int>? = null,
|
var reserved: List<Int>? = null,
|
||||||
var mtu: Int? = null,
|
var mtu: Int? = null,
|
||||||
var obfsPassword: String? = null,
|
var obfsPassword: String? = null,
|
||||||
|
var version: Int? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
data class VnextBean(
|
data class VnextBean(
|
||||||
@@ -160,7 +162,7 @@ data class V2rayConfig(
|
|||||||
var realitySettings: TlsSettingsBean? = null,
|
var realitySettings: TlsSettingsBean? = null,
|
||||||
var grpcSettings: GrpcSettingsBean? = null,
|
var grpcSettings: GrpcSettingsBean? = null,
|
||||||
var hysteriaSettings: HysteriaSettingsBean? = null,
|
var hysteriaSettings: HysteriaSettingsBean? = null,
|
||||||
var udpmasks: List<UdpMasksBean>? = null,
|
var finalmask: FinalMaskBean? = null,
|
||||||
val dsSettings: Any? = null,
|
val dsSettings: Any? = null,
|
||||||
var sockopt: SockoptBean? = null
|
var sockopt: SockoptBean? = null
|
||||||
) {
|
) {
|
||||||
@@ -200,15 +202,8 @@ data class V2rayConfig(
|
|||||||
var downlinkCapacity: Int = 100,
|
var downlinkCapacity: Int = 100,
|
||||||
var congestion: Boolean = false,
|
var congestion: Boolean = false,
|
||||||
var readBufferSize: Int = 1,
|
var readBufferSize: Int = 1,
|
||||||
var writeBufferSize: Int = 1,
|
var writeBufferSize: Int = 1
|
||||||
var header: HeaderBean = HeaderBean(),
|
)
|
||||||
var seed: String? = null
|
|
||||||
) {
|
|
||||||
data class HeaderBean(
|
|
||||||
var type: String = "none",
|
|
||||||
var domain: String? = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class WsSettingsBean(
|
data class WsSettingsBean(
|
||||||
var path: String? = null,
|
var path: String? = null,
|
||||||
@@ -270,6 +265,7 @@ data class V2rayConfig(
|
|||||||
val enableSessionResumption: Boolean? = null,
|
val enableSessionResumption: Boolean? = null,
|
||||||
var echConfigList: String? = null,
|
var echConfigList: String? = null,
|
||||||
var echForceQuery: String? = null,
|
var echForceQuery: String? = null,
|
||||||
|
var pinnedPeerCertSha256: String? = null,
|
||||||
// REALITY settings
|
// REALITY settings
|
||||||
val show: Boolean = false,
|
val show: Boolean = false,
|
||||||
var publicKey: String? = null,
|
var publicKey: String? = null,
|
||||||
@@ -307,13 +303,19 @@ data class V2rayConfig(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class UdpMasksBean(
|
data class FinalMaskBean(
|
||||||
var type: String,
|
var tcp: List<MaskBean>? = null,
|
||||||
var settings: UdpMasksSettingsBean? = null
|
var udp: List<MaskBean>? = null
|
||||||
) {
|
) {
|
||||||
data class UdpMasksSettingsBean(
|
data class MaskBean(
|
||||||
var password: String? = null
|
var type: String,
|
||||||
)
|
var settings: MaskSettingsBean? = null
|
||||||
|
) {
|
||||||
|
data class MaskSettingsBean(
|
||||||
|
var password: String? = null,
|
||||||
|
var domain: String? = null
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -1,8 +1,7 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
|
||||||
|
|
||||||
enum class EConfigType(val value: Int, val protocolScheme: String) {
|
enum class EConfigType(val value: Int, val protocolScheme: String) {
|
||||||
VMESS(1, AppConfig.VMESS),
|
VMESS(1, AppConfig.VMESS),
|
||||||
CUSTOM(2, AppConfig.CUSTOM),
|
CUSTOM(2, AppConfig.CUSTOM),
|
||||||
@@ -21,4 +20,4 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
|
|||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
enum class Language(val code: String) {
|
enum class Language(val code: String) {
|
||||||
AUTO("auto"),
|
AUTO("auto"),
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
enum class NetworkType(val type: String) {
|
enum class NetworkType(val type: String) {
|
||||||
TCP("tcp"),
|
TCP("tcp"),
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission types used in the app, handling API level differences.
|
||||||
|
*/
|
||||||
|
enum class PermissionType {
|
||||||
|
/** Camera permission (used for scanning QR codes) */
|
||||||
|
CAMERA {
|
||||||
|
override fun getPermission(): String = Manifest.permission.CAMERA
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Read storage / media permission (adapts to Android version) */
|
||||||
|
READ_STORAGE {
|
||||||
|
override fun getPermission(): String {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Manifest.permission.READ_MEDIA_IMAGES
|
||||||
|
} else {
|
||||||
|
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Notification permission (Android 13+) */
|
||||||
|
POST_NOTIFICATIONS {
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Return the actual Android permission string */
|
||||||
|
abstract fun getPermission(): String
|
||||||
|
|
||||||
|
/** Return a human-readable label for the permission */
|
||||||
|
fun getLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
CAMERA -> "Camera"
|
||||||
|
READ_STORAGE -> "Storage"
|
||||||
|
POST_NOTIFICATIONS -> "Notification"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
enum class RoutingType(val fileName: String) {
|
enum class RoutingType(val fileName: String) {
|
||||||
WHITE("custom_routing_white"),
|
WHITE("custom_routing_white"),
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VPN interface address configuration enum class
|
* VPN interface address configuration enum class
|
||||||
@@ -11,6 +11,7 @@ import com.v2ray.ang.AngApplication
|
|||||||
import es.dmoral.toasty.Toasty
|
import es.dmoral.toasty.Toasty
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
val Context.v2RayApplication: AngApplication?
|
val Context.v2RayApplication: AngApplication?
|
||||||
get() = applicationContext as? AngApplication
|
get() = applicationContext as? AngApplication
|
||||||
@@ -92,7 +93,7 @@ fun Long.toTrafficString(): String {
|
|||||||
size /= DIVISOR
|
size /= DIVISOR
|
||||||
unitIndex++
|
unitIndex++
|
||||||
}
|
}
|
||||||
return String.format("%.1f %s", size, units[unitIndex])
|
return String.format(Locale.getDefault(), "%.1f %s", size, units[unitIndex])
|
||||||
}
|
}
|
||||||
|
|
||||||
val URI.idnHost: String
|
val URI.idnHost: String
|
||||||
@@ -105,6 +106,13 @@ val URI.idnHost: String
|
|||||||
*/
|
*/
|
||||||
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
|
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns null if the string is null or blank, otherwise returns the string itself.
|
||||||
|
*
|
||||||
|
* @return The string or null.
|
||||||
|
*/
|
||||||
|
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the string to a Long value, or returns 0 if the conversion fails.
|
* Converts the string to a Long value, or returns 0 if the conversion fails.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig
|
import com.v2ray.ang.dto.V2rayConfig
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
|
||||||
object CustomFmt : FmtBase() {
|
object CustomFmt : FmtBase() {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.enums.NetworkType
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.HttpUtil
|
import com.v2ray.ang.util.HttpUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
@@ -22,17 +22,17 @@ open class FmtBase {
|
|||||||
val query = if (dicQuery != null)
|
val query = if (dicQuery != null)
|
||||||
"?" + dicQuery.toList().joinToString(
|
"?" + dicQuery.toList().joinToString(
|
||||||
separator = "&",
|
separator = "&",
|
||||||
transform = { it.first + "=" + Utils.urlEncode(it.second) })
|
transform = { it.first + "=" + Utils.encodeURIComponent(it.second) })
|
||||||
else ""
|
else ""
|
||||||
|
|
||||||
val url = String.format(
|
val url = String.format(
|
||||||
"%s@%s:%s",
|
"%s@%s:%s",
|
||||||
Utils.urlEncode(userInfo ?: ""),
|
Utils.encodeURIComponent(userInfo ?: ""),
|
||||||
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
|
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
|
||||||
config.serverPort
|
config.serverPort
|
||||||
)
|
)
|
||||||
|
|
||||||
return "${url}${query}#${Utils.urlEncode(config.remarks)}"
|
return "${url}${query}#${Utils.encodeURIComponent(config.remarks)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +43,7 @@ open class FmtBase {
|
|||||||
*/
|
*/
|
||||||
fun getQueryParam(uri: URI): Map<String, String> {
|
fun getQueryParam(uri: URI): Map<String, String> {
|
||||||
return uri.rawQuery.split("&")
|
return uri.rawQuery.split("&")
|
||||||
.associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
|
.associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +83,7 @@ open class FmtBase {
|
|||||||
config.fingerPrint = queryParam["fp"]
|
config.fingerPrint = queryParam["fp"]
|
||||||
config.alpn = queryParam["alpn"]
|
config.alpn = queryParam["alpn"]
|
||||||
config.echConfigList = queryParam["ech"]
|
config.echConfigList = queryParam["ech"]
|
||||||
|
config.pinnedCA256 = queryParam["pcs"]
|
||||||
config.publicKey = queryParam["pbk"]
|
config.publicKey = queryParam["pbk"]
|
||||||
config.shortId = queryParam["sid"]
|
config.shortId = queryParam["sid"]
|
||||||
config.spiderX = queryParam["spx"]
|
config.spiderX = queryParam["spx"]
|
||||||
@@ -99,15 +100,16 @@ open class FmtBase {
|
|||||||
fun getQueryDic(config: ProfileItem): HashMap<String, String> {
|
fun getQueryDic(config: ProfileItem): HashMap<String, String> {
|
||||||
val dicQuery = HashMap<String, String>()
|
val dicQuery = HashMap<String, String>()
|
||||||
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
|
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
|
||||||
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
|
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||||
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
|
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||||
config.echConfigList.let { if (it.isNotNullEmpty()) dicQuery["ech"] = it.orEmpty() }
|
config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
|
||||||
config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() }
|
config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
|
||||||
config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() }
|
config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
|
||||||
config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() }
|
config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
|
||||||
config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() }
|
config.shortId?.nullIfBlank()?.let { dicQuery["sid"] = it }
|
||||||
config.mldsa65Verify.let { if (it.isNotNullEmpty()) dicQuery["pqv"] = it.orEmpty() }
|
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
|
||||||
config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() }
|
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
|
||||||
|
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
|
||||||
// Add two keys for compatibility: "insecure" and "allowInsecure"
|
// Add two keys for compatibility: "insecure" and "allowInsecure"
|
||||||
if (config.security == AppConfig.TLS) {
|
if (config.security == AppConfig.TLS) {
|
||||||
val insecureFlag = if (config.insecure == true) "1" else "0"
|
val insecureFlag = if (config.insecure == true) "1" else "0"
|
||||||
@@ -121,42 +123,42 @@ open class FmtBase {
|
|||||||
when (networkType) {
|
when (networkType) {
|
||||||
NetworkType.TCP -> {
|
NetworkType.TCP -> {
|
||||||
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.KCP -> {
|
NetworkType.KCP -> {
|
||||||
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||||
config.seed.let { if (it.isNotNullEmpty()) dicQuery["seed"] = it.orEmpty() }
|
config.seed?.nullIfBlank()?.let { dicQuery["seed"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
|
NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
|
||||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.XHTTP -> {
|
NetworkType.XHTTP -> {
|
||||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||||
config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
|
config.xhttpMode?.nullIfBlank()?.let { dicQuery["mode"] = it }
|
||||||
config.xhttpExtra.let { if (it.isNotNullEmpty()) dicQuery["extra"] = it.orEmpty() }
|
config.xhttpExtra?.nullIfBlank()?.let { dicQuery["extra"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.HTTP, NetworkType.H2 -> {
|
NetworkType.HTTP, NetworkType.H2 -> {
|
||||||
dicQuery["type"] = "http"
|
dicQuery["type"] = "http"
|
||||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkType.QUIC -> {
|
// NetworkType.QUIC -> {
|
||||||
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||||
// config.quicSecurity.let { if (it.isNotNullEmpty()) dicQuery["quicSecurity"] = it.orEmpty() }
|
// config.quicSecurity?.nullIfBlank()?.let { dicQuery["quicSecurity"] = it }
|
||||||
// config.quicKey.let { if (it.isNotNullEmpty()) dicQuery["key"] = it.orEmpty() }
|
// config.quicKey?.nullIfBlank()?.let { dicQuery["key"] = it }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
NetworkType.GRPC -> {
|
NetworkType.GRPC -> {
|
||||||
config.mode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
|
config.mode?.nullIfBlank()?.let { dicQuery["mode"] = it }
|
||||||
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() }
|
config.authority?.nullIfBlank()?.let { dicQuery["authority"] = it }
|
||||||
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() }
|
config.serviceName?.nullIfBlank()?.let { dicQuery["serviceName"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.handler.V2rayConfigManager
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.UdpMasksBean.UdpMasksSettingsBean
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.handler.V2rayConfigManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
@@ -26,7 +26,7 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||||
|
|
||||||
val uri = URI(Utils.fixIllegalUrl(str))
|
val uri = URI(Utils.fixIllegalUrl(str))
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
config.password = uri.userInfo
|
config.password = uri.userInfo
|
||||||
@@ -44,7 +44,7 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
if (config.portHopping.isNotNullEmpty()) {
|
if (config.portHopping.isNotNullEmpty()) {
|
||||||
config.portHoppingInterval = queryParam["mportHopInt"]
|
config.portHoppingInterval = queryParam["mportHopInt"]
|
||||||
}
|
}
|
||||||
config.pinSHA256 = queryParam["pinSHA256"]
|
config.pinnedCA256 = queryParam["pinSHA256"]
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +61,8 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
val dicQuery = HashMap<String, String>()
|
val dicQuery = HashMap<String, String>()
|
||||||
|
|
||||||
config.security.let { if (it != null) dicQuery["security"] = it }
|
config.security.let { if (it != null) dicQuery["security"] = it }
|
||||||
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
|
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||||
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
|
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||||
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
|
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
|
||||||
|
|
||||||
if (config.obfsPassword.isNotNullEmpty()) {
|
if (config.obfsPassword.isNotNullEmpty()) {
|
||||||
@@ -75,8 +75,8 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
if (config.portHoppingInterval.isNotNullEmpty()) {
|
if (config.portHoppingInterval.isNotNullEmpty()) {
|
||||||
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
|
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
|
||||||
}
|
}
|
||||||
if (config.pinSHA256.isNotNullEmpty()) {
|
if (config.pinnedCA256.isNotNullEmpty()) {
|
||||||
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty()
|
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
return toUri(config, config.password, dicQuery)
|
return toUri(config, config.password, dicQuery)
|
||||||
@@ -96,6 +96,7 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
outboundBean.settings?.let { server ->
|
outboundBean.settings?.let { server ->
|
||||||
server.address = getServerAddress(profileItem)
|
server.address = getServerAddress(profileItem)
|
||||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
server.version = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
val sni = outboundBean.streamSettings?.let {
|
val sni = outboundBean.streamSettings?.let {
|
||||||
@@ -107,11 +108,13 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||||
outboundBean.streamSettings?.udpmasks = mutableListOf(
|
outboundBean.streamSettings?.finalmask = FinalMaskBean(
|
||||||
StreamSettingsBean.UdpMasksBean(
|
udp = listOf(
|
||||||
type = "salamander",
|
FinalMaskBean.MaskBean(
|
||||||
settings = UdpMasksSettingsBean(
|
type = "salamander",
|
||||||
password = profileItem.obfsPassword
|
settings = FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
password = profileItem.obfsPassword
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package com.v2ray.ang.fmt
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.handler.V2rayConfigManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
@@ -36,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
|
|||||||
if (uri.port <= 0) return null
|
if (uri.port <= 0) return null
|
||||||
if (uri.userInfo.isNullOrEmpty()) return null
|
if (uri.userInfo.isNullOrEmpty()) return null
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
|
|||||||
if (indexSplit > 0) {
|
if (indexSplit > 0) {
|
||||||
try {
|
try {
|
||||||
config.remarks =
|
config.remarks =
|
||||||
Utils.urlDecode(result.substring(indexSplit + 1, result.length))
|
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.handler.V2rayConfigManager
|
||||||
@@ -23,7 +23,7 @@ object SocksFmt : FmtBase() {
|
|||||||
if (uri.idnHost.isEmpty()) return null
|
if (uri.idnHost.isEmpty()) return null
|
||||||
if (uri.port <= 0) return null
|
if (uri.port <= 0) return null
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.handler.V2rayConfigManager
|
||||||
@@ -23,7 +23,7 @@ object TrojanFmt : FmtBase() {
|
|||||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||||
|
|
||||||
val uri = URI(Utils.fixIllegalUrl(str))
|
val uri = URI(Utils.fixIllegalUrl(str))
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
config.password = uri.userInfo
|
config.password = uri.userInfo
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.handler.V2rayConfigManager
|
||||||
@@ -26,7 +26,7 @@ object VlessFmt : FmtBase() {
|
|||||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||||
val queryParam = getQueryParam(uri)
|
val queryParam = getQueryParam(uri)
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
config.password = uri.userInfo
|
config.password = uri.userInfo
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package com.v2ray.ang.fmt
|
|||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
import com.v2ray.ang.dto.VmessQRCode
|
import com.v2ray.ang.dto.VmessQRCode
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.handler.V2rayConfigManager
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
@@ -132,8 +132,8 @@ object VmessFmt : FmtBase() {
|
|||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { vmessQRCode.host = it }
|
||||||
config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() }
|
config.path?.nullIfBlank()?.let { vmessQRCode.path = it }
|
||||||
|
|
||||||
vmessQRCode.tls = config.security.orEmpty()
|
vmessQRCode.tls = config.security.orEmpty()
|
||||||
vmessQRCode.sni = config.sni.orEmpty()
|
vmessQRCode.sni = config.sni.orEmpty()
|
||||||
@@ -163,7 +163,7 @@ object VmessFmt : FmtBase() {
|
|||||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||||
val queryParam = getQueryParam(uri)
|
val queryParam = getQueryParam(uri)
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
config.password = uri.userInfo
|
config.password = uri.userInfo
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package com.v2ray.ang.fmt
|
|||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.extension.removeWhiteSpace
|
import com.v2ray.ang.extension.removeWhiteSpace
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.handler.V2rayConfigManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
@@ -25,14 +26,14 @@ object WireguardFmt : FmtBase() {
|
|||||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||||
val queryParam = getQueryParam(uri)
|
val queryParam = getQueryParam(uri)
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
|
|
||||||
config.secretKey = uri.userInfo.orEmpty()
|
config.secretKey = uri.userInfo.orEmpty()
|
||||||
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||||
config.publicKey = queryParam["publickey"].orEmpty()
|
config.publicKey = queryParam["publickey"].orEmpty()
|
||||||
config.preSharedKey = queryParam["presharedkey"]?.takeIf { it.isNotEmpty() }
|
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
||||||
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||||
config.reserved = queryParam["reserved"] ?: "0,0,0"
|
config.reserved = queryParam["reserved"] ?: "0,0,0"
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ object WireguardFmt : FmtBase() {
|
|||||||
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||||
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||||
config.publicKey = peerParams["publickey"].orEmpty()
|
config.publicKey = peerParams["publickey"].orEmpty()
|
||||||
config.preSharedKey = peerParams["presharedkey"]?.takeIf { it.isNotEmpty() }
|
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
||||||
val endpoint = peerParams["endpoint"].orEmpty()
|
val endpoint = peerParams["endpoint"].orEmpty()
|
||||||
val endpointParts = endpoint.split(":", limit = 2)
|
val endpointParts = endpoint.split(":", limit = 2)
|
||||||
if (endpointParts.size == 2) {
|
if (endpointParts.size == 2) {
|
||||||
@@ -113,7 +114,7 @@ object WireguardFmt : FmtBase() {
|
|||||||
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
|
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
|
||||||
wireguard.peers?.firstOrNull()?.let { peer ->
|
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||||
peer.publicKey = profileItem.publicKey.orEmpty()
|
peer.publicKey = profileItem.publicKey.orEmpty()
|
||||||
peer.preSharedKey = profileItem.preSharedKey?.takeIf { it.isNotEmpty() }
|
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
|
||||||
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||||
}
|
}
|
||||||
wireguard.mtu = profileItem.mtu
|
wireguard.mtu = profileItem.mtu
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import android.util.Log
|
|||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.HY2
|
import com.v2ray.ang.AppConfig.HY2
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.dto.SubscriptionCache
|
||||||
import com.v2ray.ang.dto.SubscriptionItem
|
import com.v2ray.ang.dto.SubscriptionItem
|
||||||
|
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.fmt.CustomFmt
|
import com.v2ray.ang.fmt.CustomFmt
|
||||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||||
@@ -218,41 +221,137 @@ object AngConfigManager {
|
|||||||
if (servers == null) {
|
if (servers == null) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
val removedSelectedServer =
|
// Find the currently selected server that matches the subscription ID
|
||||||
if (!TextUtils.isEmpty(subid) && !append) {
|
val removedSelected = if (subid.isNotBlank() && !append) {
|
||||||
MmkvManager.decodeServerConfig(
|
MmkvManager.getSelectServer()
|
||||||
MmkvManager.getSelectServer().orEmpty()
|
.takeIf { it?.isNotBlank() == true }
|
||||||
)?.let {
|
?.let { MmkvManager.decodeServerConfig(it) }
|
||||||
if (it.subscriptionId == subid) {
|
?.takeIf { it.subscriptionId == subid }
|
||||||
return@let it
|
} else {
|
||||||
}
|
null
|
||||||
return@let null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (!append) {
|
|
||||||
MmkvManager.removeServerViaSubid(subid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val subItem = MmkvManager.decodeSubscription(subid)
|
val subItem = MmkvManager.decodeSubscription(subid)
|
||||||
var count = 0
|
|
||||||
|
// Parse all configs first (no I/O during parsing)
|
||||||
|
val configs = mutableListOf<ProfileItem>()
|
||||||
servers.lines()
|
servers.lines()
|
||||||
.distinct()
|
.distinct()
|
||||||
.reversed()
|
.reversed()
|
||||||
.forEach {
|
.forEach {
|
||||||
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
|
val config = parseConfig(it, subid, subItem)
|
||||||
if (resId == 0) {
|
if (config != null) {
|
||||||
count++
|
configs.add(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
|
// Batch save all parsed configs (only one serverList read/write)
|
||||||
|
if (configs.isNotEmpty()) {
|
||||||
|
if (!append) {
|
||||||
|
MmkvManager.removeServerViaSubid(subid)
|
||||||
|
}
|
||||||
|
val keyToProfile = batchSaveConfigs(configs, subid)
|
||||||
|
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||||
|
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs.size
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
|
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch save configurations to reduce serverList read/write operations.
|
||||||
|
* Reads serverList once, saves all configs, then writes serverList once.
|
||||||
|
*
|
||||||
|
* @param configs The list of ProfileItem to save.
|
||||||
|
* @param subid The subscription ID.
|
||||||
|
* @return Map of generated keys to their corresponding ProfileItem.
|
||||||
|
*/
|
||||||
|
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
|
||||||
|
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||||
|
|
||||||
|
// Read serverList once
|
||||||
|
val serverList = MmkvManager.decodeServerList(subid)
|
||||||
|
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
|
||||||
|
|
||||||
|
configs.forEach { config ->
|
||||||
|
val key = Utils.getUuid()
|
||||||
|
// Save profile directly without updating serverList
|
||||||
|
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||||
|
|
||||||
|
if (!serverList.contains(key)) {
|
||||||
|
serverList.add(0, key)
|
||||||
|
if (needSetSelected) {
|
||||||
|
MmkvManager.setSelectServer(key)
|
||||||
|
needSetSelected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyToProfile[key] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write serverList once
|
||||||
|
MmkvManager.encodeServerList(serverList, subid)
|
||||||
|
return keyToProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a matched profile key from the given key-profile map using multi-level matching.
|
||||||
|
* Matching priority (from highest to lowest):
|
||||||
|
* 1. Exact match: server + port + password
|
||||||
|
* 2. Match by remarks (exact match)
|
||||||
|
* 3. Match by server + port
|
||||||
|
* 4. Match by server only
|
||||||
|
*
|
||||||
|
* @param keyToProfile Map of server keys to their ProfileItem
|
||||||
|
* @param target Target profile to match
|
||||||
|
* @return Matched key or null
|
||||||
|
*/
|
||||||
|
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
|
||||||
|
if (keyToProfile.isEmpty() || target == null) return null
|
||||||
|
|
||||||
|
// Level 1: Match by remarks
|
||||||
|
if (target.remarks.isNotBlank()) {
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.remarks, target.remarks)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 2: Exact match (server + port + password)
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.server, target.server) &&
|
||||||
|
isSameText(saved.serverPort, target.serverPort) &&
|
||||||
|
isSameText(saved.password, target.password)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
|
||||||
|
// Level 3: Match by server + port
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.server, target.server) &&
|
||||||
|
isSameText(saved.serverPort, target.serverPort)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
|
||||||
|
// Level 4: Match by server only
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.server, target.server)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-insensitive trimmed string comparison.
|
||||||
|
*
|
||||||
|
* @param left First string
|
||||||
|
* @param right Second string
|
||||||
|
* @return True if both are non-empty and equal (case-insensitive, trimmed)
|
||||||
|
*/
|
||||||
|
private fun isSameText(left: String?, right: String?): Boolean {
|
||||||
|
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||||
|
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a custom configuration server.
|
* Parses a custom configuration server.
|
||||||
*
|
*
|
||||||
@@ -270,13 +369,14 @@ object AngConfigManager {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val serverList: Array<Any> =
|
val serverList: Array<Any> =
|
||||||
JsonUtil.fromJson(server, Array<Any>::class.java)?: arrayOf()
|
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||||
|
|
||||||
if (serverList.isNotEmpty()) {
|
if (serverList.isNotEmpty()) {
|
||||||
var count = 0
|
var count = 0
|
||||||
for (srv in serverList.reversed()) {
|
for (srv in serverList.reversed()) {
|
||||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
|
config.description = generateDescription(config)
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
val key = MmkvManager.encodeServerConfig("", config)
|
||||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||||
count += 1
|
count += 1
|
||||||
@@ -291,6 +391,7 @@ object AngConfigManager {
|
|||||||
// For compatibility
|
// For compatibility
|
||||||
val config = CustomFmt.parse(server) ?: return 0
|
val config = CustomFmt.parse(server) ?: return 0
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
|
config.description = generateDescription(config)
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
val key = MmkvManager.encodeServerConfig("", config)
|
||||||
MmkvManager.encodeServerRaw(key, server)
|
MmkvManager.encodeServerRaw(key, server)
|
||||||
return 1
|
return 1
|
||||||
@@ -301,6 +402,7 @@ object AngConfigManager {
|
|||||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||||
try {
|
try {
|
||||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||||
|
config.description = generateDescription(config)
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
val key = MmkvManager.encodeServerConfig("", config)
|
||||||
MmkvManager.encodeServerRaw(key, server)
|
MmkvManager.encodeServerRaw(key, server)
|
||||||
return 1
|
return 1
|
||||||
@@ -315,22 +417,21 @@ object AngConfigManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the configuration from a QR code or string.
|
* Parses the configuration from a QR code or string.
|
||||||
|
* Only parses and returns ProfileItem, does not save.
|
||||||
*
|
*
|
||||||
* @param str The configuration string.
|
* @param str The configuration string.
|
||||||
* @param subid The subscription ID.
|
* @param subid The subscription ID.
|
||||||
* @param subItem The subscription item.
|
* @param subItem The subscription item.
|
||||||
* @param removedSelectedServer The removed selected server.
|
* @return The parsed ProfileItem or null if parsing fails or filtered out.
|
||||||
* @return The result code.
|
|
||||||
*/
|
*/
|
||||||
private fun parseConfig(
|
private fun parseConfig(
|
||||||
str: String?,
|
str: String?,
|
||||||
subid: String,
|
subid: String,
|
||||||
subItem: SubscriptionItem?,
|
subItem: SubscriptionItem?
|
||||||
removedSelectedServer: ProfileItem?
|
): ProfileItem? {
|
||||||
): Int {
|
|
||||||
try {
|
try {
|
||||||
if (str == null || TextUtils.isEmpty(str)) {
|
if (str == null || TextUtils.isEmpty(str)) {
|
||||||
return R.string.toast_none_data
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
|
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
|
||||||
@@ -352,75 +453,75 @@ object AngConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
return R.string.toast_incorrect_protocol
|
return null
|
||||||
}
|
}
|
||||||
//filter
|
|
||||||
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
|
// Apply filter
|
||||||
val matched = Regex(pattern = subItem.filter ?: "")
|
if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
|
||||||
|
val matched = Regex(pattern = subItem?.filter.orEmpty())
|
||||||
.containsMatchIn(input = config.remarks)
|
.containsMatchIn(input = config.remarks)
|
||||||
if (!matched) return -1
|
if (!matched) return null
|
||||||
}
|
}
|
||||||
|
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
val guid = MmkvManager.encodeServerConfig("", config)
|
config.description = generateDescription(config)
|
||||||
if (removedSelectedServer != null &&
|
|
||||||
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
|
return config
|
||||||
) {
|
|
||||||
MmkvManager.setSelectServer(guid)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to parse config", e)
|
Log.e(AppConfig.TAG, "Failed to parse config", e)
|
||||||
return -1
|
return null
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the configuration via all subscriptions.
|
* Updates the configuration via all subscriptions.
|
||||||
*
|
*
|
||||||
* @return The number of configurations updated.
|
* @return Detailed result of the subscription update operation.
|
||||||
*/
|
*/
|
||||||
fun updateConfigViaSubAll(): Int {
|
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
|
||||||
var count = 0
|
return try {
|
||||||
try {
|
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||||
MmkvManager.decodeSubscriptions().forEach {
|
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
|
||||||
count += updateConfigViaSub(it)
|
acc + updateConfigViaSub(subscription)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||||
return 0
|
SubscriptionUpdateResult()
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the configuration via a subscription.
|
* Updates the configuration via a subscription.
|
||||||
*
|
*
|
||||||
* @param it The subscription item.
|
* @param it The subscription item.
|
||||||
* @return The number of configurations updated.
|
* @return Subscription update result.
|
||||||
*/
|
*/
|
||||||
fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int {
|
fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
|
||||||
try {
|
try {
|
||||||
if (TextUtils.isEmpty(it.first)
|
// Check if disabled
|
||||||
|| TextUtils.isEmpty(it.second.remarks)
|
if (!it.subscription.enabled) {
|
||||||
|| TextUtils.isEmpty(it.second.url)
|
return SubscriptionUpdateResult(skipCount = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate subscription info
|
||||||
|
if (TextUtils.isEmpty(it.guid)
|
||||||
|
|| TextUtils.isEmpty(it.subscription.remarks)
|
||||||
|
|| TextUtils.isEmpty(it.subscription.url)
|
||||||
) {
|
) {
|
||||||
return 0
|
return SubscriptionUpdateResult(skipCount = 1)
|
||||||
}
|
}
|
||||||
if (!it.second.enabled) {
|
|
||||||
return 0
|
val url = HttpUtil.toIdnUrl(it.subscription.url)
|
||||||
}
|
|
||||||
val url = HttpUtil.toIdnUrl(it.second.url)
|
|
||||||
if (!Utils.isValidUrl(url)) {
|
if (!Utils.isValidUrl(url)) {
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
if (!it.second.allowInsecureUrl) {
|
if (!it.subscription.allowInsecureUrl) {
|
||||||
if (!Utils.isValidSubUrl(url)) {
|
if (!Utils.isValidSubUrl(url)) {
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.i(AppConfig.TAG, url)
|
Log.i(AppConfig.TAG, url)
|
||||||
val userAgent = it.second.userAgent
|
val userAgent = it.subscription.userAgent
|
||||||
|
|
||||||
var configText = try {
|
var configText = try {
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
@@ -438,18 +539,25 @@ object AngConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (configText.isEmpty()) {
|
if (configText.isEmpty()) {
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
val count = parseConfigViaSub(configText, it.first, false)
|
|
||||||
|
val count = parseConfigViaSub(configText, it.guid, false)
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
it.second.lastUpdated = System.currentTimeMillis()
|
it.subscription.lastUpdated = System.currentTimeMillis()
|
||||||
MmkvManager.encodeSubscription(it.first, it.second)
|
MmkvManager.encodeSubscription(it.guid, it.subscription)
|
||||||
Log.i(AppConfig.TAG, "Subscription updated: ${it.second.remarks}, $count configs")
|
Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
||||||
|
return SubscriptionUpdateResult(
|
||||||
|
configCount = count,
|
||||||
|
successCount = 1
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Got response but no valid configs parsed
|
||||||
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +589,7 @@ object AngConfigManager {
|
|||||||
private fun importUrlAsSubscription(url: String): Int {
|
private fun importUrlAsSubscription(url: String): Int {
|
||||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||||
subscriptions.forEach {
|
subscriptions.forEach {
|
||||||
if (it.second.url == url) {
|
if (it.subscription.url == url) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,4 +600,25 @@ object AngConfigManager {
|
|||||||
MmkvManager.encodeSubscription("", subItem)
|
MmkvManager.encodeSubscription("", subItem)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generates a description for the profile.
|
||||||
|
*
|
||||||
|
* @param profile The profile item.
|
||||||
|
* @return The generated description.
|
||||||
|
*/
|
||||||
|
fun generateDescription(profile: ProfileItem): String {
|
||||||
|
// Hide xxx:xxx:***/xxx.xxx.xxx.***
|
||||||
|
val server = profile.server
|
||||||
|
val port = profile.serverPort
|
||||||
|
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
|
||||||
|
|
||||||
|
val addrPart = server?.let {
|
||||||
|
if (it.contains(":"))
|
||||||
|
it.split(":").take(2).joinToString(":", postfix = ":***")
|
||||||
|
else
|
||||||
|
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
|
||||||
|
} ?: ""
|
||||||
|
|
||||||
|
return "$addrPart : ${port ?: ""}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
import com.tencent.mmkv.MMKV
|
import com.tencent.mmkv.MMKV
|
||||||
|
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||||
|
import com.v2ray.ang.dto.AssetUrlCache
|
||||||
import com.v2ray.ang.dto.AssetUrlItem
|
import com.v2ray.ang.dto.AssetUrlItem
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
import com.v2ray.ang.dto.RulesetItem
|
||||||
import com.v2ray.ang.dto.ServerAffiliationInfo
|
import com.v2ray.ang.dto.ServerAffiliationInfo
|
||||||
|
import com.v2ray.ang.dto.SubscriptionCache
|
||||||
import com.v2ray.ang.dto.SubscriptionItem
|
import com.v2ray.ang.dto.SubscriptionItem
|
||||||
import com.v2ray.ang.dto.WebDavConfig
|
import com.v2ray.ang.dto.WebDavConfig
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
@@ -16,7 +19,6 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region private
|
//region private
|
||||||
|
|
||||||
//private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
|
|
||||||
private const val ID_MAIN = "MAIN"
|
private const val ID_MAIN = "MAIN"
|
||||||
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
||||||
private const val ID_SERVER_RAW = "SERVER_RAW"
|
private const val ID_SERVER_RAW = "SERVER_RAW"
|
||||||
@@ -26,10 +28,10 @@ object MmkvManager {
|
|||||||
private const val ID_SETTING = "SETTING"
|
private const val ID_SETTING = "SETTING"
|
||||||
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
||||||
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
||||||
|
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
|
||||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||||
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||||
|
|
||||||
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
|
||||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||||
@@ -42,6 +44,17 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region Server
|
//region Server
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
|
||||||
|
* This method is for migration purposes only.
|
||||||
|
*
|
||||||
|
* @return The JSON string of legacy server list, or null if not exists.
|
||||||
|
*/
|
||||||
|
fun readLegacyServerList(): String? {
|
||||||
|
return mainStorage.decodeString(KEY_ANG_CONFIGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the selected server GUID.
|
* Gets the selected server GUID.
|
||||||
*
|
*
|
||||||
@@ -61,21 +74,31 @@ object MmkvManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the server list.
|
* Encodes the server list for a given subscription.
|
||||||
|
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
|
||||||
*
|
*
|
||||||
* @param serverList The list of server GUIDs.
|
* @param serverList The list of server GUIDs.
|
||||||
|
* @param subscriptionId The subscription ID.
|
||||||
*/
|
*/
|
||||||
fun encodeServerList(serverList: MutableList<String>) {
|
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||||
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
|
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||||
|
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes the server list.
|
* Decodes the server list for a given subscription.
|
||||||
|
* If subscriptionId is empty, returns ungrouped servers.
|
||||||
|
* Otherwise, returns servers from the specified subscription's serverList.
|
||||||
*
|
*
|
||||||
|
* @param subscriptionId The subscription ID.
|
||||||
* @return The list of server GUIDs.
|
* @return The list of server GUIDs.
|
||||||
*/
|
*/
|
||||||
fun decodeServerList(): MutableList<String> {
|
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||||
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
|
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||||
|
val json = mainStorage.decodeString(key)
|
||||||
return if (json.isNullOrBlank()) {
|
return if (json.isNullOrBlank()) {
|
||||||
mutableListOf()
|
mutableListOf()
|
||||||
} else {
|
} else {
|
||||||
@@ -83,6 +106,30 @@ object MmkvManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes all server list (merged from all subscriptions including default subscription).
|
||||||
|
* Use this when you need the complete server list.
|
||||||
|
*
|
||||||
|
* @return The list of all server GUIDs.
|
||||||
|
*/
|
||||||
|
fun decodeAllServerList(): MutableList<String> {
|
||||||
|
val allServers = mutableListOf<String>()
|
||||||
|
val subsList = decodeSubsList()
|
||||||
|
|
||||||
|
// If DEFAULT_SUBSCRIPTION_ID is not in the subscriptions list, add its servers
|
||||||
|
if (!subsList.contains(DEFAULT_SUBSCRIPTION_ID)) {
|
||||||
|
allServers.addAll(decodeServerList(DEFAULT_SUBSCRIPTION_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add servers from all subscriptions
|
||||||
|
subsList.forEach { guid ->
|
||||||
|
allServers.addAll(decodeServerList(guid))
|
||||||
|
}
|
||||||
|
|
||||||
|
return allServers
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes the server configuration.
|
* Decodes the server configuration.
|
||||||
*
|
*
|
||||||
@@ -100,16 +147,6 @@ object MmkvManager {
|
|||||||
return JsonUtil.fromJson(json, ProfileItem::class.java)
|
return JsonUtil.fromJson(json, ProfileItem::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fun decodeProfileConfig(guid: String): ProfileLiteItem? {
|
|
||||||
// if (guid.isBlank()) {
|
|
||||||
// return null
|
|
||||||
// }
|
|
||||||
// val json = profileStorage.decodeString(guid)
|
|
||||||
// if (json.isNullOrBlank()) {
|
|
||||||
// return null
|
|
||||||
// }
|
|
||||||
// return JsonUtil.fromJson(json, ProfileLiteItem::class.java)
|
|
||||||
// }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the server configuration.
|
* Encodes the server configuration.
|
||||||
@@ -121,25 +158,32 @@ object MmkvManager {
|
|||||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||||
val key = guid.ifBlank { Utils.getUuid() }
|
val key = guid.ifBlank { Utils.getUuid() }
|
||||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||||
val serverList = decodeServerList()
|
|
||||||
|
// Use default subscription for servers without subscription
|
||||||
|
val subId = getSubscriptionId(config.subscriptionId)
|
||||||
|
val serverList = decodeServerList(subId)
|
||||||
|
|
||||||
if (!serverList.contains(key)) {
|
if (!serverList.contains(key)) {
|
||||||
serverList.add(0, key)
|
serverList.add(0, key)
|
||||||
encodeServerList(serverList)
|
encodeServerList(serverList, subId)
|
||||||
if (getSelectServer().isNullOrBlank()) {
|
if (getSelectServer().isNullOrBlank()) {
|
||||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// val profile = ProfileLiteItem(
|
|
||||||
// configType = config.configType,
|
|
||||||
// subscriptionId = config.subscriptionId,
|
|
||||||
// remarks = config.remarks,
|
|
||||||
// server = config.getProxyOutbound()?.getServerAddress(),
|
|
||||||
// serverPort = config.getProxyOutbound()?.getServerPort(),
|
|
||||||
// )
|
|
||||||
// profileStorage.encode(key, JsonUtil.toJson(profile))
|
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the server configuration directly without updating serverList.
|
||||||
|
*
|
||||||
|
* @param key The server GUID.
|
||||||
|
* @param configJson The server configuration JSON string.
|
||||||
|
*/
|
||||||
|
fun encodeProfileDirect(key: String, configJson: String) {
|
||||||
|
profileFullStorage.encode(key, configJson)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the server configuration.
|
* Removes the server configuration.
|
||||||
*
|
*
|
||||||
@@ -149,33 +193,44 @@ object MmkvManager {
|
|||||||
if (guid.isBlank()) {
|
if (guid.isBlank()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get config to determine which subscription to update
|
||||||
|
val config = decodeServerConfig(guid)
|
||||||
|
val subId = getSubscriptionId(config?.subscriptionId)
|
||||||
|
|
||||||
|
// Remove from appropriate server list
|
||||||
|
val serverList = decodeServerList(subId)
|
||||||
|
serverList.remove(guid)
|
||||||
|
encodeServerList(serverList, subId)
|
||||||
|
|
||||||
|
// Clean up storage
|
||||||
if (getSelectServer() == guid) {
|
if (getSelectServer() == guid) {
|
||||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||||
}
|
}
|
||||||
val serverList = decodeServerList()
|
|
||||||
serverList.remove(guid)
|
|
||||||
encodeServerList(serverList)
|
|
||||||
profileFullStorage.remove(guid)
|
profileFullStorage.remove(guid)
|
||||||
//profileStorage.remove(guid)
|
|
||||||
serverAffStorage.remove(guid)
|
serverAffStorage.remove(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the server configurations via subscription ID.
|
* Removes the server configurations via subscription ID.
|
||||||
*
|
*
|
||||||
* @param subid The subscription ID.
|
* @param subscriptionId The subscription ID.
|
||||||
*/
|
*/
|
||||||
fun removeServerViaSubid(subid: String) {
|
fun removeServerViaSubid(subscriptionId: String?) {
|
||||||
if (subid.isBlank()) {
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
return
|
val serverList = decodeServerList(subId)
|
||||||
}
|
|
||||||
profileFullStorage.allKeys()?.forEach { key ->
|
// Remove all servers in the list
|
||||||
decodeServerConfig(key)?.let { config ->
|
serverList.forEach { guid ->
|
||||||
if (config.subscriptionId == subid) {
|
if (getSelectServer() == guid) {
|
||||||
removeServer(key)
|
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
profileFullStorage.remove(guid)
|
||||||
|
serverAffStorage.remove(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serverList.clear()
|
||||||
|
encodeServerList(serverList, subId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -233,7 +288,6 @@ object MmkvManager {
|
|||||||
val count = profileFullStorage.allKeys()?.count() ?: 0
|
val count = profileFullStorage.allKeys()?.count() ?: 0
|
||||||
mainStorage.clearAll()
|
mainStorage.clearAll()
|
||||||
profileFullStorage.clearAll()
|
profileFullStorage.clearAll()
|
||||||
//profileStorage.clearAll()
|
|
||||||
serverAffStorage.clearAll()
|
serverAffStorage.clearAll()
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
@@ -290,6 +344,10 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region Subscriptions
|
//region Subscriptions
|
||||||
|
|
||||||
|
private fun getSubscriptionId(subscriptionId: String?):String {
|
||||||
|
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the subscription list.
|
* Initializes the subscription list.
|
||||||
*/
|
*/
|
||||||
@@ -309,15 +367,15 @@ object MmkvManager {
|
|||||||
*
|
*
|
||||||
* @return The list of subscriptions.
|
* @return The list of subscriptions.
|
||||||
*/
|
*/
|
||||||
fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> {
|
fun decodeSubscriptions(): List<SubscriptionCache> {
|
||||||
initSubsList()
|
initSubsList()
|
||||||
|
|
||||||
val subscriptions = mutableListOf<Pair<String, SubscriptionItem>>()
|
val subscriptions = mutableListOf<SubscriptionCache>()
|
||||||
decodeSubsList().forEach { key ->
|
decodeSubsList().forEach { key ->
|
||||||
val json = subStorage.decodeString(key)
|
val json = subStorage.decodeString(key)
|
||||||
if (!json.isNullOrBlank()) {
|
if (!json.isNullOrBlank()) {
|
||||||
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
|
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
|
||||||
subscriptions.add(Pair(key, item))
|
subscriptions.add(SubscriptionCache(key, item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return subscriptions
|
return subscriptions
|
||||||
@@ -397,16 +455,16 @@ object MmkvManager {
|
|||||||
*
|
*
|
||||||
* @return The list of asset URLs.
|
* @return The list of asset URLs.
|
||||||
*/
|
*/
|
||||||
fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> {
|
fun decodeAssetUrls(): List<AssetUrlCache> {
|
||||||
val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>()
|
val assetUrlItems = mutableListOf<AssetUrlCache>()
|
||||||
assetStorage.allKeys()?.forEach { key ->
|
assetStorage.allKeys()?.forEach { key ->
|
||||||
val json = assetStorage.decodeString(key)
|
val json = assetStorage.decodeString(key)
|
||||||
if (!json.isNullOrBlank()) {
|
if (!json.isNullOrBlank()) {
|
||||||
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
|
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
|
||||||
assetUrlItems.add(Pair(key, item))
|
assetUrlItems.add(AssetUrlCache(key, item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return assetUrlItems.sortedBy { (_, value) -> value.addedTime }
|
return assetUrlItems.sortedBy { it.assetUrl.addedTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
@@ -30,6 +31,7 @@ object NotificationManager {
|
|||||||
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
||||||
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
||||||
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
||||||
|
private const val QUERY_INTERVAL_MS = 3000L
|
||||||
|
|
||||||
private var lastQueryTime = 0L
|
private var lastQueryTime = 0L
|
||||||
private var mBuilder: NotificationCompat.Builder? = null
|
private var mBuilder: NotificationCompat.Builder? = null
|
||||||
@@ -44,7 +46,6 @@ object NotificationManager {
|
|||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
||||||
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
|
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
|
||||||
|
|
||||||
lastQueryTime = System.currentTimeMillis()
|
|
||||||
var lastZeroSpeed = false
|
var lastZeroSpeed = false
|
||||||
val outboundTags = currentConfig?.getAllOutboundTags()
|
val outboundTags = currentConfig?.getAllOutboundTags()
|
||||||
outboundTags?.remove(AppConfig.TAG_DIRECT)
|
outboundTags?.remove(AppConfig.TAG_DIRECT)
|
||||||
@@ -52,7 +53,17 @@ object NotificationManager {
|
|||||||
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val queryTime = System.currentTimeMillis()
|
val queryTime = System.currentTimeMillis()
|
||||||
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
|
val sinceLastQueryIn = (queryTime - lastQueryTime)
|
||||||
|
|
||||||
|
// If the query interval is too short, skip this round to avoid excessive CPU usage
|
||||||
|
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
|
||||||
|
Log.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
|
||||||
|
lastQueryTime = queryTime
|
||||||
|
delay(QUERY_INTERVAL_MS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
|
||||||
|
|
||||||
var proxyTotal = 0L
|
var proxyTotal = 0L
|
||||||
val text = StringBuilder()
|
val text = StringBuilder()
|
||||||
outboundTags?.forEach {
|
outboundTags?.forEach {
|
||||||
@@ -78,7 +89,7 @@ object NotificationManager {
|
|||||||
}
|
}
|
||||||
lastZeroSpeed = zeroSpeed
|
lastZeroSpeed = zeroSpeed
|
||||||
lastQueryTime = queryTime
|
lastQueryTime = queryTime
|
||||||
delay(3000)
|
delay(QUERY_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +100,10 @@ object NotificationManager {
|
|||||||
*/
|
*/
|
||||||
fun showNotification(currentConfig: ProfileItem?) {
|
fun showNotification(currentConfig: ProfileItem?) {
|
||||||
val service = getService() ?: return
|
val service = getService() ?: return
|
||||||
|
|
||||||
|
// Reset last query time to avoid querying stats too soon after showing the notification
|
||||||
|
lastQueryTime = System.currentTimeMillis()
|
||||||
|
|
||||||
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
|
||||||
val startMainIntent = Intent(service, MainActivity::class.java)
|
val startMainIntent = Intent(service, MainActivity::class.java)
|
||||||
|
|||||||
@@ -7,18 +7,25 @@ import android.util.Log
|
|||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||||
|
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||||
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
||||||
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
||||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.AppConfig.VPN
|
||||||
import com.v2ray.ang.dto.Language
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.RoutingType
|
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
import com.v2ray.ang.dto.RulesetItem
|
||||||
|
import com.v2ray.ang.dto.SubscriptionItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig
|
import com.v2ray.ang.dto.V2rayConfig
|
||||||
import com.v2ray.ang.dto.VpnInterfaceAddressConfig
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.Language
|
||||||
|
import com.v2ray.ang.enums.RoutingType
|
||||||
|
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeServerList
|
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.removeSubscription
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -28,11 +35,19 @@ import java.util.Locale
|
|||||||
|
|
||||||
object SettingsManager {
|
object SettingsManager {
|
||||||
|
|
||||||
|
fun initApp(context: Context) {
|
||||||
|
ensureDefaultSettings()
|
||||||
|
//ensureDefaultSubscription()
|
||||||
|
initRoutingRulesets(context)
|
||||||
|
migrateServerListToSubscriptions()
|
||||||
|
migrateHysteria2PinSHA256()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize routing rulesets.
|
* Initialize routing rulesets.
|
||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
*/
|
*/
|
||||||
fun initRoutingRulesets(context: Context) {
|
private fun initRoutingRulesets(context: Context) {
|
||||||
val exist = MmkvManager.decodeRoutingRulesets()
|
val exist = MmkvManager.decodeRoutingRulesets()
|
||||||
if (exist.isNullOrEmpty()) {
|
if (exist.isNullOrEmpty()) {
|
||||||
val rulesetList = getPresetRoutingRulesets(context)
|
val rulesetList = getPresetRoutingRulesets(context)
|
||||||
@@ -220,14 +235,37 @@ object SettingsManager {
|
|||||||
if (remarks.isNullOrEmpty()) {
|
if (remarks.isNullOrEmpty()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val serverList = decodeServerList()
|
val serverList = decodeAllServerList()
|
||||||
for (guid in serverList) {
|
return serverList
|
||||||
val profile = decodeServerConfig(guid)
|
.mapNotNull { guid -> decodeServerConfig(guid) }
|
||||||
if (profile != null && profile.remarks == remarks) {
|
.firstOrNull { it.remarks == remarks }
|
||||||
return profile
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Removes the subscription.
|
||||||
|
* If there are no remaining subscriptions,
|
||||||
|
* it creates a new default subscription to ensure that ungroup
|
||||||
|
**/
|
||||||
|
fun removeSubscriptionWithDefault(subid: String) {
|
||||||
|
// val subsList = decodeSubsList()
|
||||||
|
// if (subsList.size == 1 && subsList.first() == DEFAULT_SUBSCRIPTION_ID) {
|
||||||
|
// Log.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Remove the subscription
|
||||||
|
removeSubscription(subid)
|
||||||
|
|
||||||
|
// After removal, check if there are any subscriptions left. If not, create a default subscription.
|
||||||
|
val subsList2 = decodeSubsList()
|
||||||
|
if (subsList2.isNotEmpty()) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
|
val defaultSub = SubscriptionItem(
|
||||||
|
remarks = "Default",
|
||||||
|
)
|
||||||
|
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,7 +293,7 @@ object SettingsManager {
|
|||||||
val extFolder = Utils.userAssetPath(context)
|
val extFolder = Utils.userAssetPath(context)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val geo = arrayOf("geosite.dat", "geoip.dat")
|
val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||||
assets.list("")
|
assets.list("")
|
||||||
?.filter { geo.contains(it) }
|
?.filter { geo.contains(it) }
|
||||||
?.filter { !File(extFolder, it).exists() }
|
?.filter { !File(extFolder, it).exists() }
|
||||||
@@ -386,10 +424,19 @@ object SettingsManager {
|
|||||||
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
|
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if VPN mode is enabled.
|
||||||
|
* @return True if VPN mode is enabled, false otherwise.
|
||||||
|
*/
|
||||||
|
fun isVpnMode(): Boolean {
|
||||||
|
val mode = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE)
|
||||||
|
return mode == null || mode == VPN
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure default settings are present in MMKV.
|
* Ensure default settings are present in MMKV.
|
||||||
*/
|
*/
|
||||||
fun ensureDefaultSettings() {
|
private fun ensureDefaultSettings() {
|
||||||
// Write defaults in the exact order requested by the user
|
// Write defaults in the exact order requested by the user
|
||||||
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
|
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
|
||||||
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
|
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
|
||||||
@@ -412,4 +459,100 @@ object SettingsManager {
|
|||||||
MmkvManager.encodeSettings(key, default)
|
MmkvManager.encodeSettings(key, default)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun migrateHysteria2PinSHA256() {
|
||||||
|
// Check if migration has already been done
|
||||||
|
val migrationKey = "hysteria2_pin_sha256_migrated"
|
||||||
|
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverList = decodeAllServerList()
|
||||||
|
|
||||||
|
for (guid in serverList) {
|
||||||
|
val profile = decodeServerConfig(guid) ?: continue
|
||||||
|
if (profile.configType != EConfigType.HYSTERIA2) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (profile.pinSHA256.isNullOrEmpty() || !profile.pinnedCA256.isNullOrEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
profile.pinnedCA256 = profile.pinSHA256
|
||||||
|
profile.pinSHA256 = null
|
||||||
|
MmkvManager.encodeServerConfig(guid, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
MmkvManager.encodeSettings(migrationKey, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
|
||||||
|
* This method should be called once during app initialization after the storage structure change.
|
||||||
|
* Servers are grouped by their subscriptionId into respective subscription's serverList.
|
||||||
|
* Servers without subscription are moved to the default subscription.
|
||||||
|
* After migration, KEY_ANG_CONFIGS is removed.
|
||||||
|
*/
|
||||||
|
private fun migrateServerListToSubscriptions() {
|
||||||
|
// Check if migration has already been done
|
||||||
|
val migrationKey = "server_list_to_subscriptions_migrated"
|
||||||
|
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure default subscription exists before migration
|
||||||
|
ensureDefaultSubscription()
|
||||||
|
|
||||||
|
// Read existing server list from legacy KEY_ANG_CONFIGS
|
||||||
|
val oldJson = MmkvManager.readLegacyServerList()
|
||||||
|
if (oldJson.isNullOrBlank()) {
|
||||||
|
// No data to migrate, mark as done
|
||||||
|
MmkvManager.encodeSettings(migrationKey, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
|
||||||
|
MmkvManager.encodeSettings(migrationKey, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
|
||||||
|
|
||||||
|
// Group servers by subscription (use default subscription for empty subscriptionId)
|
||||||
|
guids.forEach { guid ->
|
||||||
|
val config = decodeServerConfig(guid) ?: return@forEach
|
||||||
|
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||||
|
|
||||||
|
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each subscription's serverList (including default subscription)
|
||||||
|
subscriptionServerMap.forEach { (subId, serverGuids) ->
|
||||||
|
MmkvManager.encodeServerList(serverGuids, subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Mark migration as complete
|
||||||
|
MmkvManager.encodeSettings(migrationKey, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the default subscription exists for ungrouped servers.
|
||||||
|
* This subscription is used internally to store servers without a subscription.
|
||||||
|
* Made public for migration in SettingsManager.
|
||||||
|
*/
|
||||||
|
private fun ensureDefaultSubscription() {
|
||||||
|
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
|
||||||
|
val defaultSub = SubscriptionItem(
|
||||||
|
remarks = "Default",
|
||||||
|
)
|
||||||
|
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||||
|
|
||||||
|
// Move top
|
||||||
|
val subsList = decodeSubsList()
|
||||||
|
if (subsList.count() > 1) {
|
||||||
|
swapSubscriptions(0, subsList.count() - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ object SubscriptionUpdater {
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
Log.i(AppConfig.TAG, "subscription automatic update starting")
|
Log.i(AppConfig.TAG, "subscription automatic update starting")
|
||||||
|
|
||||||
val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate }
|
val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
|
||||||
|
|
||||||
for (sub in subs) {
|
for (sub in subs) {
|
||||||
val subItem = sub.second
|
val subItem = sub.subscription
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
||||||
@@ -53,7 +53,7 @@ object SubscriptionUpdater {
|
|||||||
}
|
}
|
||||||
notificationManager.notify(3, notification.build())
|
notificationManager.notify(3, notification.build())
|
||||||
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
|
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
|
||||||
AngConfigManager.updateConfigViaSub(Pair(sub.first, subItem))
|
AngConfigManager.updateConfigViaSub(sub)
|
||||||
notification.setContentText("Updating ${subItem.remarks}")
|
notification.setContentText("Updating ${subItem.remarks}")
|
||||||
}
|
}
|
||||||
notificationManager.cancel(3)
|
notificationManager.cancel(3)
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.contracts.ServiceControl
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.service.ServiceControl
|
|
||||||
import com.v2ray.ang.service.V2RayProxyOnlyService
|
import com.v2ray.ang.service.V2RayProxyOnlyService
|
||||||
import com.v2ray.ang.service.V2RayVpnService
|
import com.v2ray.ang.service.V2RayVpnService
|
||||||
import com.v2ray.ang.util.MessageUtil
|
import com.v2ray.ang.util.MessageUtil
|
||||||
@@ -57,9 +57,12 @@ object V2RayServiceManager {
|
|||||||
* @param guid The GUID of the server configuration to use (optional).
|
* @param guid The GUID of the server configuration to use (optional).
|
||||||
*/
|
*/
|
||||||
fun startVService(context: Context, guid: String? = null) {
|
fun startVService(context: Context, guid: String? = null) {
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||||
|
|
||||||
if (guid != null) {
|
if (guid != null) {
|
||||||
MmkvManager.setSelectServer(guid)
|
MmkvManager.setSelectServer(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
startContextService(context)
|
startContextService(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +94,30 @@ object V2RayServiceManager {
|
|||||||
*/
|
*/
|
||||||
private fun startContextService(context: Context) {
|
private fun startContextService(context: Context) {
|
||||||
if (coreController.isRunning) {
|
if (coreController.isRunning) {
|
||||||
|
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val guid = MmkvManager.getSelectServer() ?: return
|
|
||||||
val config = MmkvManager.decodeServerConfig(guid) ?: return
|
val guid = MmkvManager.getSelectServer()
|
||||||
|
if (guid == null) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = MmkvManager.decodeServerConfig(guid)
|
||||||
|
if (config == null) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (config.configType != EConfigType.CUSTOM
|
if (config.configType != EConfigType.CUSTOM
|
||||||
&& config.configType != EConfigType.POLICYGROUP
|
&& config.configType != EConfigType.POLICYGROUP
|
||||||
&& !Utils.isValidUrl(config.server)
|
&& !Utils.isValidUrl(config.server)
|
||||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||||
) return
|
) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||||
// if (!result.status) return
|
// if (!result.status) return
|
||||||
|
|
||||||
@@ -108,12 +126,21 @@ object V2RayServiceManager {
|
|||||||
} else {
|
} else {
|
||||||
context.toast(R.string.toast_services_start)
|
context.toast(R.string.toast_services_start)
|
||||||
}
|
}
|
||||||
val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: AppConfig.VPN) == AppConfig.VPN) {
|
|
||||||
|
val isVpnMode = SettingsManager.isVpnMode()
|
||||||
|
val intent = if (isVpnMode) {
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||||
Intent(context.applicationContext, V2RayVpnService::class.java)
|
Intent(context.applicationContext, V2RayVpnService::class.java)
|
||||||
} else {
|
} else {
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
|
||||||
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
|
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
|
try {
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,15 +150,34 @@ object V2RayServiceManager {
|
|||||||
*/
|
*/
|
||||||
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
||||||
if (coreController.isRunning) {
|
if (coreController.isRunning) {
|
||||||
|
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val service = getService() ?: return false
|
val service = getService()
|
||||||
val guid = MmkvManager.getSelectServer() ?: return false
|
if (service == null) {
|
||||||
val config = MmkvManager.decodeServerConfig(guid) ?: return false
|
Log.e(AppConfig.TAG, "StartCore-Manager: Service is null")
|
||||||
val result = V2rayConfigManager.getV2rayConfig(service, guid)
|
|
||||||
if (!result.status)
|
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val guid = MmkvManager.getSelectServer()
|
||||||
|
if (guid == null) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = MmkvManager.decodeServerConfig(guid)
|
||||||
|
if (config == null) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
|
||||||
|
val result = V2rayConfigManager.getV2rayConfig(service, guid)
|
||||||
|
if (!result.status) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||||
@@ -140,7 +186,7 @@ object V2RayServiceManager {
|
|||||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,13 +197,15 @@ object V2RayServiceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
NotificationManager.showNotification(currentConfig)
|
||||||
coreController.startLoop(result.content, tunFd)
|
coreController.startLoop(result.content, tunFd)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coreController.isRunning == false) {
|
if (coreController.isRunning == false) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
|
||||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
|
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
|
||||||
NotificationManager.cancelNotification()
|
NotificationManager.cancelNotification()
|
||||||
return false
|
return false
|
||||||
@@ -165,11 +213,10 @@ object V2RayServiceManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||||
NotificationManager.showNotification(currentConfig)
|
|
||||||
NotificationManager.startSpeedNotification(currentConfig)
|
NotificationManager.startSpeedNotification(currentConfig)
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to startup service", e)
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -188,7 +235,7 @@ object V2RayServiceManager {
|
|||||||
try {
|
try {
|
||||||
coreController.stopLoop()
|
coreController.stopLoop()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +246,7 @@ object V2RayServiceManager {
|
|||||||
try {
|
try {
|
||||||
service.unregisterReceiver(mMsgReceive)
|
service.unregisterReceiver(mMsgReceive)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -233,14 +280,14 @@ object V2RayServiceManager {
|
|||||||
try {
|
try {
|
||||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||||
}
|
}
|
||||||
if (time == -1L) {
|
if (time == -1L) {
|
||||||
try {
|
try {
|
||||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e)
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +339,7 @@ object V2RayServiceManager {
|
|||||||
serviceControl.stopService()
|
serviceControl.stopService()
|
||||||
0
|
0
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
|
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||||
-1
|
-1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,12 +386,12 @@ object V2RayServiceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppConfig.MSG_STATE_STOP -> {
|
AppConfig.MSG_STATE_STOP -> {
|
||||||
Log.i(AppConfig.TAG, "Stop Service")
|
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||||
serviceControl.stopService()
|
serviceControl.stopService()
|
||||||
}
|
}
|
||||||
|
|
||||||
AppConfig.MSG_STATE_RESTART -> {
|
AppConfig.MSG_STATE_RESTART -> {
|
||||||
Log.i(AppConfig.TAG, "Restart Service")
|
Log.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||||
serviceControl.stopService()
|
serviceControl.stopService()
|
||||||
Thread.sleep(500L)
|
Thread.sleep(500L)
|
||||||
startVService(serviceControl.getService())
|
startVService(serviceControl.getService())
|
||||||
@@ -357,12 +404,12 @@ object V2RayServiceManager {
|
|||||||
|
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
Intent.ACTION_SCREEN_OFF -> {
|
Intent.ACTION_SCREEN_OFF -> {
|
||||||
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
|
Log.i(AppConfig.TAG, "StartCore-Manager: Screen off")
|
||||||
NotificationManager.stopSpeedNotification(currentConfig)
|
NotificationManager.stopSpeedNotification(currentConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
Intent.ACTION_SCREEN_ON -> {
|
Intent.ACTION_SCREEN_ON -> {
|
||||||
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
|
Log.i(AppConfig.TAG, "StartCore-Manager: Screen on")
|
||||||
NotificationManager.startSpeedNotification(currentConfig)
|
NotificationManager.startSpeedNotification(currentConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import android.util.Log
|
|||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.ConfigResult
|
import com.v2ray.ang.dto.ConfigResult
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
import com.v2ray.ang.dto.RulesetItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig
|
import com.v2ray.ang.dto.V2rayConfig
|
||||||
@@ -15,7 +13,10 @@ import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
|
||||||
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
|
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.fmt.HttpFmt
|
import com.v2ray.ang.fmt.HttpFmt
|
||||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||||
@@ -91,7 +92,7 @@ object V2rayConfigManager {
|
|||||||
private fun getV2rayCustomConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
|
private fun getV2rayCustomConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
|
||||||
val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
|
val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
|
||||||
val result = ConfigResult(true, guid, raw)
|
val result = ConfigResult(true, guid, raw)
|
||||||
if (SettingsManager.isUsingHevTun()) {
|
if (!needTun()) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +139,7 @@ object V2rayConfigManager {
|
|||||||
private fun getV2rayGroupConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
|
private fun getV2rayGroupConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
|
||||||
val result = ConfigResult(false)
|
val result = ConfigResult(false)
|
||||||
|
|
||||||
val serverList = MmkvManager.decodeServerList()
|
val serverList = MmkvManager.decodeAllServerList()
|
||||||
val configList = serverList
|
val configList = serverList
|
||||||
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
||||||
.filter { profile ->
|
.filter { profile ->
|
||||||
@@ -334,18 +335,18 @@ object V2rayConfigManager {
|
|||||||
*/
|
*/
|
||||||
private fun initV2rayConfig(context: Context): V2rayConfig? {
|
private fun initV2rayConfig(context: Context): V2rayConfig? {
|
||||||
var assets = ""
|
var assets = ""
|
||||||
if (SettingsManager.isUsingHevTun()) {
|
if (needTun()) {
|
||||||
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
|
|
||||||
if (TextUtils.isEmpty(assets)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
initConfigCache = assets
|
|
||||||
} else {
|
|
||||||
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
|
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
|
||||||
if (TextUtils.isEmpty(assets)) {
|
if (TextUtils.isEmpty(assets)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
initConfigCacheWithTun = assets
|
initConfigCacheWithTun = assets
|
||||||
|
} else {
|
||||||
|
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
|
||||||
|
if (TextUtils.isEmpty(assets)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
initConfigCache = assets
|
||||||
}
|
}
|
||||||
val config = JsonUtil.fromJson(assets, V2rayConfig::class.java)
|
val config = JsonUtil.fromJson(assets, V2rayConfig::class.java)
|
||||||
return config
|
return config
|
||||||
@@ -357,6 +358,10 @@ object V2rayConfigManager {
|
|||||||
|
|
||||||
//region some sub function
|
//region some sub function
|
||||||
|
|
||||||
|
private fun needTun(): Boolean {
|
||||||
|
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configures the inbound settings for V2ray.
|
* Configures the inbound settings for V2ray.
|
||||||
*
|
*
|
||||||
@@ -395,9 +400,10 @@ object V2rayConfigManager {
|
|||||||
v2rayConfig.inbounds.add(inbound2)
|
v2rayConfig.inbounds.add(inbound2)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SettingsManager.isUsingHevTun()) {
|
if (needTun()) {
|
||||||
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
|
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
|
||||||
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
|
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
|
||||||
|
inboundTun?.sniffing = inbound1.sniffing
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to configure inbounds", e)
|
Log.e(AppConfig.TAG, "Failed to configure inbounds", e)
|
||||||
@@ -461,6 +467,19 @@ object V2rayConfigManager {
|
|||||||
|
|
||||||
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), RulesBean::class.java) ?: return
|
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), RulesBean::class.java) ?: return
|
||||||
|
|
||||||
|
// Replace specific geoip rules with ext versions
|
||||||
|
rule.ip?.let { ipList ->
|
||||||
|
val updatedIpList = ArrayList<String>()
|
||||||
|
ipList.forEach { ip ->
|
||||||
|
when (ip) {
|
||||||
|
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
|
||||||
|
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
|
||||||
|
else -> updatedIpList.add(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.ip = updatedIpList
|
||||||
|
}
|
||||||
|
|
||||||
v2rayConfig.routing.rules.add(rule)
|
v2rayConfig.routing.rules.add(rule)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -519,23 +538,25 @@ object V2rayConfigManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SettingsManager.isUsingHevTun()) {
|
if(SettingsManager.isVpnMode()) {
|
||||||
//hev-socks5-tunnel dns routing
|
if (SettingsManager.isUsingHevTun()) {
|
||||||
v2rayConfig.routing.rules.add(
|
//hev-socks5-tunnel dns routing
|
||||||
0, RulesBean(
|
v2rayConfig.routing.rules.add(
|
||||||
inboundTag = arrayListOf("socks"),
|
0, RulesBean(
|
||||||
outboundTag = "dns-out",
|
inboundTag = arrayListOf("socks"),
|
||||||
port = "53",
|
outboundTag = "dns-out",
|
||||||
|
port = "53",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
v2rayConfig.routing.rules.add(
|
||||||
v2rayConfig.routing.rules.add(
|
0, RulesBean(
|
||||||
0, RulesBean(
|
inboundTag = arrayListOf("tun"),
|
||||||
inboundTag = arrayListOf("tun"),
|
outboundTag = "dns-out",
|
||||||
outboundTag = "dns-out",
|
port = "53",
|
||||||
port = "53",
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS outbound
|
// DNS outbound
|
||||||
@@ -768,13 +789,14 @@ object V2rayConfigManager {
|
|||||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||||
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|
||||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||||
) {
|
) {
|
||||||
muxEnabled = false
|
muxEnabled = false
|
||||||
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
||||||
muxEnabled = false
|
muxEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (muxEnabled == true) {
|
if (muxEnabled) {
|
||||||
outbound.mux?.enabled = true
|
outbound.mux?.enabled = true
|
||||||
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
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?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
||||||
@@ -833,65 +855,93 @@ object V2rayConfigManager {
|
|||||||
* Configures load balancing settings for the V2ray configuration.
|
* Configures load balancing settings for the V2ray configuration.
|
||||||
*
|
*
|
||||||
* @param v2rayConfig The V2ray configuration object to be modified with balancing settings
|
* @param v2rayConfig The V2ray configuration object to be modified with balancing settings
|
||||||
|
* @param config The profile item containing policy group settings
|
||||||
*/
|
*/
|
||||||
private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) {
|
private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) {
|
||||||
try {
|
try {
|
||||||
v2rayConfig.routing.rules.forEach { rule ->
|
v2rayConfig.routing.rules.forEach { rule ->
|
||||||
if (rule.outboundTag == "proxy") {
|
if (rule.outboundTag == "proxy") {
|
||||||
rule.outboundTag = null
|
rule.outboundTag = null
|
||||||
rule.balancerTag = "proxy-round"
|
rule.balancerTag = AppConfig.TAG_BALANCER
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.policyGroupType == "0") {
|
val lstSelector = listOf("proxy-")
|
||||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
when (config.policyGroupType) {
|
||||||
tag = "proxy-round",
|
// Least Ping goto else
|
||||||
selector = listOf("proxy-"),
|
"1" -> {
|
||||||
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
// Least Load
|
||||||
type = "leastPing"
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
|
tag = AppConfig.TAG_BALANCER,
|
||||||
|
selector = lstSelector,
|
||||||
|
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||||
|
type = "leastLoad"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
v2rayConfig.routing.balancers = listOf(balancer)
|
||||||
v2rayConfig.routing.balancers = listOf(balancer)
|
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
|
||||||
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
|
subjectSelector = lstSelector,
|
||||||
subjectSelector = listOf("proxy-"),
|
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
||||||
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
||||||
probeInterval = "3m",
|
interval = "5m",
|
||||||
enableConcurrency = true
|
sampling = 2,
|
||||||
)
|
timeout = "30s"
|
||||||
} else {
|
)
|
||||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
|
||||||
tag = "proxy-round",
|
|
||||||
selector = listOf("proxy-"),
|
|
||||||
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
|
||||||
type = "leastLoad"
|
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
v2rayConfig.routing.balancers = listOf(balancer)
|
"2" -> {
|
||||||
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
|
// Random
|
||||||
subjectSelector = listOf("proxy-"),
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
tag = AppConfig.TAG_BALANCER,
|
||||||
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
selector = lstSelector,
|
||||||
interval = "5m",
|
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||||
sampling = 2,
|
type = "random"
|
||||||
timeout = "30s"
|
)
|
||||||
)
|
)
|
||||||
)
|
v2rayConfig.routing.balancers = listOf(balancer)
|
||||||
|
}
|
||||||
|
"3" -> {
|
||||||
|
// Round Robin
|
||||||
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
|
tag = AppConfig.TAG_BALANCER,
|
||||||
|
selector = lstSelector,
|
||||||
|
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||||
|
type = "roundRobin"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v2rayConfig.routing.balancers = listOf(balancer)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Default: Least Ping
|
||||||
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
|
tag = AppConfig.TAG_BALANCER,
|
||||||
|
selector = lstSelector,
|
||||||
|
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||||
|
type = "leastPing"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v2rayConfig.routing.balancers = listOf(balancer)
|
||||||
|
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
|
||||||
|
subjectSelector = lstSelector,
|
||||||
|
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
||||||
|
probeInterval = "3m",
|
||||||
|
enableConcurrency = true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
|
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
|
||||||
v2rayConfig.routing.rules.add(
|
v2rayConfig.routing.rules.add(
|
||||||
RulesBean(
|
RulesBean(
|
||||||
ip = arrayListOf("0.0.0.0/0", "::/0"),
|
ip = arrayListOf("0.0.0.0/0", "::/0"),
|
||||||
balancerTag = "proxy-round",
|
balancerTag = AppConfig.TAG_BALANCER,
|
||||||
type = "field"
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
v2rayConfig.routing.rules.add(
|
v2rayConfig.routing.rules.add(
|
||||||
RulesBean(
|
RulesBean(
|
||||||
network = "tcp,udp",
|
network = "tcp,udp",
|
||||||
balancerTag = "proxy-round",
|
balancerTag = AppConfig.TAG_BALANCER,
|
||||||
type = "field"
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1147,19 +1197,39 @@ object V2rayConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.KCP.type -> {
|
NetworkType.KCP.type -> {
|
||||||
val kcpsetting = StreamSettingsBean.KcpSettingsBean()
|
streamSettings.kcpSettings = StreamSettingsBean.KcpSettingsBean()
|
||||||
kcpsetting.header.type = headerType ?: "none"
|
val udpMaskList = mutableListOf<StreamSettingsBean.FinalMaskBean.MaskBean>()
|
||||||
|
if (!headerType.isNullOrEmpty() && headerType != "none") {
|
||||||
|
val kcpHeaderType = when {
|
||||||
|
headerType == "wechat-video" -> "header-wechat"
|
||||||
|
else -> "header-$headerType"
|
||||||
|
}
|
||||||
|
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = kcpHeaderType,
|
||||||
|
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
|
||||||
|
StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
domain = host
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
if (seed.isNullOrEmpty()) {
|
if (seed.isNullOrEmpty()) {
|
||||||
kcpsetting.seed = null
|
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "mkcp-original"
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
kcpsetting.seed = seed
|
udpMaskList.add(StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "mkcp-aes128gcm",
|
||||||
|
settings = StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
password = seed
|
||||||
|
)
|
||||||
|
))
|
||||||
}
|
}
|
||||||
if (host.isNullOrEmpty()) {
|
streamSettings.finalmask = StreamSettingsBean.FinalMaskBean(
|
||||||
kcpsetting.header.domain = null
|
udp = udpMaskList.toList()
|
||||||
} else {
|
)
|
||||||
kcpsetting.header.domain = host
|
|
||||||
}
|
|
||||||
streamSettings.kcpSettings = kcpsetting
|
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.WS.type -> {
|
NetworkType.WS.type -> {
|
||||||
@@ -1261,28 +1331,21 @@ object V2rayConfigManager {
|
|||||||
} else {
|
} else {
|
||||||
profileItem.sni
|
profileItem.sni
|
||||||
}
|
}
|
||||||
val fingerprint = profileItem.fingerPrint
|
|
||||||
val alpns = profileItem.alpn
|
|
||||||
val echConfigList = profileItem.echConfigList
|
|
||||||
val echForceQuery = profileItem.echForceQuery
|
|
||||||
val publicKey = profileItem.publicKey
|
|
||||||
val shortId = profileItem.shortId
|
|
||||||
val spiderX = profileItem.spiderX
|
|
||||||
val mldsa65Verify = profileItem.mldsa65Verify
|
|
||||||
|
|
||||||
streamSettings.security = if (streamSecurity.isEmpty()) null else streamSecurity
|
streamSettings.security = streamSecurity.nullIfBlank()
|
||||||
if (streamSettings.security == null) return
|
if (streamSettings.security == null) return
|
||||||
val tlsSetting = StreamSettingsBean.TlsSettingsBean(
|
val tlsSetting = StreamSettingsBean.TlsSettingsBean(
|
||||||
allowInsecure = allowInsecure,
|
allowInsecure = allowInsecure,
|
||||||
serverName = if (sni.isNullOrEmpty()) null else sni,
|
serverName = sni.nullIfBlank(),
|
||||||
fingerprint = if (fingerprint.isNullOrEmpty()) null else fingerprint,
|
fingerprint = profileItem.fingerPrint.nullIfBlank(),
|
||||||
alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
|
||||||
echConfigList = if (echConfigList.isNullOrEmpty()) null else echConfigList,
|
echConfigList = profileItem.echConfigList.nullIfBlank(),
|
||||||
echForceQuery = if (echForceQuery.isNullOrEmpty()) null else echForceQuery,
|
echForceQuery = profileItem.echForceQuery.nullIfBlank(),
|
||||||
publicKey = if (publicKey.isNullOrEmpty()) null else publicKey,
|
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
|
||||||
shortId = if (shortId.isNullOrEmpty()) null else shortId,
|
publicKey = profileItem.publicKey.nullIfBlank(),
|
||||||
spiderX = if (spiderX.isNullOrEmpty()) null else spiderX,
|
shortId = profileItem.shortId.nullIfBlank(),
|
||||||
mldsa65Verify = if (mldsa65Verify.isNullOrEmpty()) null else mldsa65Verify,
|
spiderX = profileItem.spiderX.nullIfBlank(),
|
||||||
|
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
|
||||||
)
|
)
|
||||||
if (streamSettings.security == AppConfig.TLS) {
|
if (streamSettings.security == AppConfig.TLS) {
|
||||||
streamSettings.tlsSettings = tlsSetting
|
streamSettings.tlsSettings = tlsSetting
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.extension.toast
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for choosing and creating files using Android Storage Access Framework.
|
||||||
|
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
|
||||||
|
*/
|
||||||
|
class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||||
|
private var fileChooserCallback: ((Uri?) -> Unit)? = null
|
||||||
|
private var documentCreateCallback: ((Uri?) -> Unit)? = null
|
||||||
|
|
||||||
|
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
val uri = result.data?.data
|
||||||
|
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
|
||||||
|
fileChooserCallback?.invoke(uri)
|
||||||
|
} else {
|
||||||
|
fileChooserCallback?.invoke(null)
|
||||||
|
}
|
||||||
|
fileChooserCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val documentCreateLauncher: ActivityResultLauncher<String> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
|
||||||
|
documentCreateCallback?.invoke(uri)
|
||||||
|
documentCreateCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch file chooser with ACTION_GET_CONTENT intent to select an existing file.
|
||||||
|
*
|
||||||
|
* @param mimeType MIME type filter for files
|
||||||
|
* @param onResult Callback invoked with the selected file URI (null if cancelled)
|
||||||
|
*/
|
||||||
|
fun launch(
|
||||||
|
mimeType: String = "*/*",
|
||||||
|
onResult: (Uri?) -> Unit
|
||||||
|
) {
|
||||||
|
fileChooserCallback = onResult
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||||
|
type = mimeType
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fileChooserLauncher.launch(
|
||||||
|
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
|
||||||
|
)
|
||||||
|
} catch (ex: ActivityNotFoundException) {
|
||||||
|
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||||
|
activity.toast(R.string.toast_require_file_manager)
|
||||||
|
fileChooserCallback?.invoke(null)
|
||||||
|
fileChooserCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch document creator to create a new file at user-selected location.
|
||||||
|
*
|
||||||
|
* @param fileName Default file name for the new document
|
||||||
|
* @param onResult Callback invoked with the created file URI (null if cancelled)
|
||||||
|
*/
|
||||||
|
fun createDocument(
|
||||||
|
fileName: String,
|
||||||
|
onResult: (Uri?) -> Unit
|
||||||
|
) {
|
||||||
|
documentCreateCallback = onResult
|
||||||
|
try {
|
||||||
|
documentCreateLauncher.launch(fileName)
|
||||||
|
} catch (ex: ActivityNotFoundException) {
|
||||||
|
Log.e(AppConfig.TAG, "Document creator activity not found", ex)
|
||||||
|
activity.toast(R.string.toast_require_file_manager)
|
||||||
|
documentCreateCallback?.invoke(null)
|
||||||
|
documentCreateCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -1,7 +1,10 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
import androidx.preference.PreferenceDataStore
|
import androidx.preference.PreferenceDataStore
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
||||||
@@ -76,5 +79,6 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
|
|||||||
}
|
}
|
||||||
// Notify listeners that require service restart or reinit
|
// Notify listeners that require service restart or reinit
|
||||||
SettingsChangeManager.makeRestartService()
|
SettingsChangeManager.makeRestartService()
|
||||||
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.enums.PermissionType
|
||||||
|
import com.v2ray.ang.extension.toast
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for requesting permissions.
|
||||||
|
*/
|
||||||
|
class PermissionHelper(private val activity: AppCompatActivity) {
|
||||||
|
private var permissionCallback: ((Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
private val permissionLauncher: ActivityResultLauncher<String> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||||
|
permissionCallback?.invoke(isGranted)
|
||||||
|
permissionCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the permission and request it if not granted.
|
||||||
|
*
|
||||||
|
* @param permissionType the type of permission
|
||||||
|
* @param onGranted called when permission is granted (called immediately if already granted)
|
||||||
|
*/
|
||||||
|
fun request(permissionType: PermissionType, onGranted: () -> Unit) {
|
||||||
|
val permission = permissionType.getPermission()
|
||||||
|
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
onGranted()
|
||||||
|
} else {
|
||||||
|
permissionCallback = { isGranted ->
|
||||||
|
if (isGranted) {
|
||||||
|
onGranted()
|
||||||
|
} else {
|
||||||
|
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
|
||||||
|
activity.toast(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
permissionLauncher.launch(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.v2ray.ang.ui.ScannerActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for scanning QR codes.
|
||||||
|
*
|
||||||
|
* This class encapsulates the logic for launching the QR code scanner activity
|
||||||
|
* and handling the scan result.
|
||||||
|
*/
|
||||||
|
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
|
||||||
|
private var scanCallback: ((String?) -> Unit)? = null
|
||||||
|
|
||||||
|
private val scanLauncher: ActivityResultLauncher<Intent> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||||
|
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
|
||||||
|
scanCallback?.invoke(scanResult)
|
||||||
|
} else {
|
||||||
|
scanCallback?.invoke(null)
|
||||||
|
}
|
||||||
|
scanCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch the QR code scanner activity.
|
||||||
|
*
|
||||||
|
* @param onResult Callback invoked with the scan result (null if cancelled or failed)
|
||||||
|
*/
|
||||||
|
fun launch(onResult: (String?) -> Unit) {
|
||||||
|
scanCallback = onResult
|
||||||
|
scanLauncher.launch(Intent(activity, ScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.v2ray.ang.receiver
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.handler.V2RayServiceManager
|
||||||
|
|
||||||
@@ -16,8 +18,24 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
* @param intent The Intent being received.
|
* @param intent The Intent being received.
|
||||||
*/
|
*/
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
Log.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
|
||||||
if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
|
|
||||||
|
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
|
||||||
|
Log.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MmkvManager.decodeStartOnBoot()) {
|
||||||
|
Log.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||||
|
Log.w(AppConfig.TAG, "BootReceiver: No server selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
|
||||||
V2RayServiceManager.startVService(context)
|
V2RayServiceManager.startVService(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.contracts.Tun2SocksControl
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import android.app.Service
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.contracts.ServiceControl
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.handler.V2RayServiceManager
|
||||||
import com.v2ray.ang.util.MyContextWrapper
|
import com.v2ray.ang.util.MyContextWrapper
|
||||||
@@ -15,6 +18,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
|||||||
*/
|
*/
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-Proxy: Service created")
|
||||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
V2RayServiceManager.serviceControl = SoftReference(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
|||||||
* @return The start mode.
|
* @return The start mode.
|
||||||
*/
|
*/
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
|
||||||
V2RayServiceManager.startCoreLoop(null)
|
V2RayServiceManager.startCoreLoop(null)
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import android.os.IBinder
|
|||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
|
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
|
||||||
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
|
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
|
||||||
|
import com.v2ray.ang.dto.TestServiceMessage
|
||||||
import com.v2ray.ang.extension.serializable
|
import com.v2ray.ang.extension.serializable
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2RayNativeManager
|
import com.v2ray.ang.handler.V2RayNativeManager
|
||||||
import com.v2ray.ang.util.MessageUtil
|
import com.v2ray.ang.util.MessageUtil
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
@@ -52,10 +54,18 @@ class V2RayTestService : Service() {
|
|||||||
* @return The start mode.
|
* @return The start mode.
|
||||||
*/
|
*/
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.getIntExtra("key", 0)) {
|
val message = intent?.serializable<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
|
||||||
|
when (message.key) {
|
||||||
MSG_MEASURE_CONFIG -> {
|
MSG_MEASURE_CONFIG -> {
|
||||||
val guidsList = intent.serializable<ArrayList<String>>("content")
|
val guidsList = if (message.serverGuids.isNotEmpty()) {
|
||||||
if (guidsList != null && guidsList.isNotEmpty()) {
|
message.serverGuids
|
||||||
|
} else if (message.subscriptionId.isNotEmpty()) {
|
||||||
|
MmkvManager.decodeServerList(message.subscriptionId)
|
||||||
|
} else {
|
||||||
|
MmkvManager.decodeAllServerList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guidsList.isNotEmpty()) {
|
||||||
lateinit var worker: RealPingWorkerService
|
lateinit var worker: RealPingWorkerService
|
||||||
worker = RealPingWorkerService(this, guidsList) { status ->
|
worker = RealPingWorkerService(this, guidsList) { status ->
|
||||||
// notify UI and remove the worker from active list when finished
|
// notify UI and remove the worker from active list when finished
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.v2ray.ang.service
|
package com.v2ray.ang.service
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -18,6 +19,8 @@ import androidx.annotation.RequiresApi
|
|||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
|
import com.v2ray.ang.contracts.ServiceControl
|
||||||
|
import com.v2ray.ang.contracts.Tun2SocksControl
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.NotificationManager
|
import com.v2ray.ang.handler.NotificationManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
@@ -26,6 +29,7 @@ import com.v2ray.ang.util.MyContextWrapper
|
|||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.lang.ref.SoftReference
|
import java.lang.ref.SoftReference
|
||||||
|
|
||||||
|
@SuppressLint("VpnServicePolicy")
|
||||||
class V2RayVpnService : VpnService(), ServiceControl {
|
class V2RayVpnService : VpnService(), ServiceControl {
|
||||||
private lateinit var mInterface: ParcelFileDescriptor
|
private lateinit var mInterface: ParcelFileDescriptor
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
@@ -70,13 +74,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-VPN: Service created")
|
||||||
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
|
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
|
||||||
StrictMode.setThreadPolicy(policy)
|
StrictMode.setThreadPolicy(policy)
|
||||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
V2RayServiceManager.serviceControl = SoftReference(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRevoke() {
|
override fun onRevoke() {
|
||||||
stopV2Ray()
|
Log.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
|
||||||
|
stopAllService()
|
||||||
}
|
}
|
||||||
|
|
||||||
// override fun onLowMemory() {
|
// override fun onLowMemory() {
|
||||||
@@ -86,10 +92,12 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
|
||||||
NotificationManager.cancelNotification()
|
NotificationManager.cancelNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.i(AppConfig.TAG, "StartCore-VPN: Service command received")
|
||||||
setupVpnService()
|
setupVpnService()
|
||||||
startService()
|
startService()
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
@@ -101,15 +109,19 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun startService() {
|
override fun startService() {
|
||||||
if (mInterface == null) {
|
if (!::mInterface.isInitialized) {
|
||||||
Log.e(AppConfig.TAG, "Failed to create VPN interface")
|
Log.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
|
||||||
|
stopAllService()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
V2RayServiceManager.startCoreLoop(mInterface)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stopService() {
|
override fun stopService() {
|
||||||
stopV2Ray(true)
|
stopAllService(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun vpnProtect(socket: Int): Boolean {
|
override fun vpnProtect(socket: Int): Boolean {
|
||||||
@@ -130,10 +142,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
private fun setupVpnService() {
|
private fun setupVpnService() {
|
||||||
val prepare = prepare(this)
|
val prepare = prepare(this)
|
||||||
if (prepare != null) {
|
if (prepare != null) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
|
||||||
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configureVpnService() != true) {
|
if (configureVpnService() != true) {
|
||||||
|
Log.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
|
||||||
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +171,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
|
|
||||||
// Close the old interface since the parameters have been changed
|
// Close the old interface since the parameters have been changed
|
||||||
try {
|
try {
|
||||||
mInterface.close()
|
if (::mInterface.isInitialized) {
|
||||||
} catch (ignored: Exception) {
|
mInterface.close()
|
||||||
// ignored
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(AppConfig.TAG, "Failed to close old interface", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure platform-specific features
|
// Configure platform-specific features
|
||||||
@@ -170,7 +188,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
|
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
|
||||||
stopV2Ray()
|
stopAllService()
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -234,7 +252,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
try {
|
try {
|
||||||
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
|
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to request default network", e)
|
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +305,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
builder.addAllowedApplication(it)
|
builder.addAllowedApplication(it)
|
||||||
}
|
}
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e)
|
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,11 +329,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
tun2SocksService?.startTun2Socks()
|
tun2SocksService?.startTun2Socks()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun stopAllService(isForced: Boolean = true) {
|
||||||
* Stops the V2Ray service.
|
|
||||||
* @param isForced Whether to force stop the service.
|
|
||||||
*/
|
|
||||||
private fun stopV2Ray(isForced: Boolean = true) {
|
|
||||||
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
|
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
|
||||||
// val emptyInfo = VpnNetworkInfo()
|
// val emptyInfo = VpnNetworkInfo()
|
||||||
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
|
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
|
||||||
@@ -324,8 +338,8 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
try {
|
try {
|
||||||
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
|
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
|
||||||
} catch (ignored: Exception) {
|
} catch (e: Exception) {
|
||||||
// ignored
|
Log.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,9 +357,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mInterface.close()
|
if (::mInterface.isInitialized) {
|
||||||
|
mInterface.close()
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
|
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.tencent.mmkv.MMKV
|
import com.tencent.mmkv.MMKV
|
||||||
@@ -20,11 +14,11 @@ import com.v2ray.ang.R
|
|||||||
import com.v2ray.ang.databinding.ActivityBackupBinding
|
import com.v2ray.ang.databinding.ActivityBackupBinding
|
||||||
import com.v2ray.ang.databinding.DialogWebdavBinding
|
import com.v2ray.ang.databinding.DialogWebdavBinding
|
||||||
import com.v2ray.ang.dto.WebDavConfig
|
import com.v2ray.ang.dto.WebDavConfig
|
||||||
import com.v2ray.ang.extension.toast
|
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.handler.WebDavManager
|
import com.v2ray.ang.handler.WebDavManager
|
||||||
import com.v2ray.ang.util.ZipUtil
|
import com.v2ray.ang.util.ZipUtil
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -34,50 +28,13 @@ import java.io.File
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class BackupActivity : BaseActivity() {
|
class BackupActivity : HelperBaseActivity() {
|
||||||
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
|
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
|
||||||
|
|
||||||
private val config_backup_options: Array<out String> by lazy {
|
private val config_backup_options: Array<out String> by lazy {
|
||||||
resources.getStringArray(R.array.config_backup_options)
|
resources.getStringArray(R.array.config_backup_options)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val requestPermissionLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
|
||||||
if (isGranted) {
|
|
||||||
try {
|
|
||||||
showFileChooser()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast(R.string.toast_permission_denied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val createBackupFile =
|
|
||||||
registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
try {
|
|
||||||
val ret = backupConfigurationToCache()
|
|
||||||
if (ret.first) {
|
|
||||||
// Copy the cached zip file to user-selected location
|
|
||||||
contentResolver.openOutputStream(uri)?.use { output ->
|
|
||||||
File(ret.second).inputStream().use { input ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clean up cache file
|
|
||||||
File(ret.second).delete()
|
|
||||||
toastSuccess(R.string.toast_success)
|
|
||||||
} else {
|
|
||||||
toastError(R.string.toast_failure)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
|
|
||||||
toastError(R.string.toast_failure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -168,46 +125,35 @@ class BackupActivity : BaseActivity() {
|
|||||||
val count = MMKV.restoreAllFromDirectory(backupDir)
|
val count = MMKV.restoreAllFromDirectory(backupDir)
|
||||||
SettingsChangeManager.makeSetupGroupTab()
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
SettingsChangeManager.makeRestartService()
|
SettingsChangeManager.makeRestartService()
|
||||||
|
|
||||||
|
SettingsManager.initApp(this)
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFileChooser() {
|
private fun showFileChooser() {
|
||||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
launchFileChooser { uri ->
|
||||||
type = "*/*"
|
if (uri == null) {
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
return@launchFileChooser
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
try {
|
val targetFile =
|
||||||
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
|
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
|
||||||
} catch (ex: ActivityNotFoundException) {
|
contentResolver.openInputStream(uri).use { input ->
|
||||||
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
|
targetFile.outputStream().use { fileOut ->
|
||||||
toast(R.string.toast_require_file_manager)
|
input?.copyTo(fileOut)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val chooseFile =
|
|
||||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
||||||
val uri = result.data?.data
|
|
||||||
if (result.resultCode == RESULT_OK && uri != null) {
|
|
||||||
try {
|
|
||||||
val targetFile =
|
|
||||||
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
|
|
||||||
contentResolver.openInputStream(uri).use { input ->
|
|
||||||
targetFile.outputStream().use { fileOut ->
|
|
||||||
input?.copyTo(fileOut)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (restoreConfiguration(targetFile)) {
|
}
|
||||||
toastSuccess(R.string.toast_success)
|
if (restoreConfiguration(targetFile)) {
|
||||||
} else {
|
toastSuccess(R.string.toast_success)
|
||||||
toastError(R.string.toast_failure)
|
} else {
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Error during file restore", e)
|
|
||||||
toastError(R.string.toast_failure)
|
toastError(R.string.toast_failure)
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "Error during file restore", e)
|
||||||
|
toastError(R.string.toast_failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun backupViaLocal() {
|
private fun backupViaLocal() {
|
||||||
val dateFormatted = SimpleDateFormat(
|
val dateFormatted = SimpleDateFormat(
|
||||||
@@ -215,25 +161,34 @@ class BackupActivity : BaseActivity() {
|
|||||||
Locale.getDefault()
|
Locale.getDefault()
|
||||||
).format(System.currentTimeMillis())
|
).format(System.currentTimeMillis())
|
||||||
val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
|
val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
|
||||||
createBackupFile.launch(defaultFileName)
|
|
||||||
|
launchCreateDocument(defaultFileName) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
try {
|
||||||
|
val ret = backupConfigurationToCache()
|
||||||
|
if (ret.first) {
|
||||||
|
// Copy the cached zip file to user-selected location
|
||||||
|
contentResolver.openOutputStream(uri)?.use { output ->
|
||||||
|
File(ret.second).inputStream().use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up cache file
|
||||||
|
File(ret.second).delete()
|
||||||
|
toastSuccess(R.string.toast_success)
|
||||||
|
} else {
|
||||||
|
toastError(R.string.toast_failure)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
|
||||||
|
toastError(R.string.toast_failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreViaLocal() {
|
private fun restoreViaLocal() {
|
||||||
val permission =
|
showFileChooser()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
Manifest.permission.READ_MEDIA_IMAGES
|
|
||||||
} else {
|
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
}
|
|
||||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
try {
|
|
||||||
showFileChooser()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
requestPermissionLauncher.launch(permission)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun backupViaWebDav() {
|
private fun backupViaWebDav() {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ abstract class BaseActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
if (!Utils.getDarkModeStatus(this)) {
|
if (!Utils.getDarkModeStatus(this)) {
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
@@ -212,5 +213,4 @@ abstract class BaseActivity : AppCompatActivity() {
|
|||||||
protected fun isLoadingVisible(): Boolean {
|
protected fun isLoadingVisible(): Boolean {
|
||||||
return progressBar?.visibility == View.VISIBLE
|
return progressBar?.visibility == View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import com.v2ray.ang.dto.GroupMapItem
|
|||||||
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
|
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
|
||||||
override fun getItemCount(): Int = groups.size
|
override fun getItemCount(): Int = groups.size
|
||||||
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
|
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
|
||||||
|
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
|
||||||
|
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
fun update(groups: List<GroupMapItem>) {
|
fun update(groups: List<GroupMapItem>) {
|
||||||
|
|||||||
@@ -1,27 +1,50 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.TAG
|
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.contracts.MainAdapterListener
|
||||||
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
||||||
|
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.toast
|
||||||
|
import com.v2ray.ang.extension.toastError
|
||||||
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||||
import com.v2ray.ang.viewmodel.MainViewModel
|
import com.v2ray.ang.viewmodel.MainViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||||
|
SwipeRefreshLayout.OnRefreshListener {
|
||||||
|
private val ownerActivity: MainActivity
|
||||||
|
get() = requireActivity() as MainActivity
|
||||||
private val mainViewModel: MainViewModel by activityViewModels()
|
private val mainViewModel: MainViewModel by activityViewModels()
|
||||||
private lateinit var adapter: MainRecyclerAdapter
|
private lateinit var adapter: MainRecyclerAdapter
|
||||||
private var itemTouchHelper: ItemTouchHelper? = null
|
private var itemTouchHelper: ItemTouchHelper? = null
|
||||||
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
|
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
|
||||||
|
|
||||||
|
private val share_method: Array<out String> by lazy {
|
||||||
|
ownerActivity.resources.getStringArray(R.array.share_method)
|
||||||
|
}
|
||||||
|
private val share_method_more: Array<out String> by lazy {
|
||||||
|
ownerActivity.resources.getStringArray(R.array.share_method_more)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ARG_SUB_ID = "subscriptionId"
|
private const val ARG_SUB_ID = "subscriptionId"
|
||||||
fun newInstance(subId: String) = GroupServerFragment().apply {
|
fun newInstance(subId: String) = GroupServerFragment().apply {
|
||||||
@@ -34,7 +57,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|
||||||
adapter = MainRecyclerAdapter(requireActivity() as MainActivity)
|
adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
|
||||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
|
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
|
||||||
@@ -47,22 +70,248 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
||||||
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||||
|
|
||||||
|
binding.refreshLayout.setOnRefreshListener(this)
|
||||||
|
|
||||||
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
||||||
if (mainViewModel.subscriptionId != subId) {
|
if (mainViewModel.subscriptionId != subId) {
|
||||||
return@observe
|
return@observe
|
||||||
}
|
}
|
||||||
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
// Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||||
adapter.setData(mainViewModel.serversCache, index)
|
adapter.setData(mainViewModel.serversCache, index)
|
||||||
}
|
}
|
||||||
mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
|
|
||||||
adapter.isRunning = isRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
mainViewModel.subscriptionIdChanged(subId)
|
mainViewModel.subscriptionIdChanged(subId)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Shares server configuration
|
||||||
|
* Displays a dialog with sharing options and executes the selected action
|
||||||
|
* @param guid The server unique identifier
|
||||||
|
* @param profile The server configuration
|
||||||
|
* @param position The position in the list
|
||||||
|
* @param shareOptions The list of share options
|
||||||
|
* @param skip The number of options to skip
|
||||||
|
*/
|
||||||
|
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
|
||||||
|
AlertDialog.Builder(ownerActivity).setItems(shareOptions.toTypedArray()) { _, i ->
|
||||||
|
try {
|
||||||
|
when (i + skip) {
|
||||||
|
0 -> showQRCode(guid)
|
||||||
|
1 -> share2Clipboard(guid)
|
||||||
|
2 -> shareFullContent(guid)
|
||||||
|
3 -> editServer(guid, profile)
|
||||||
|
4 -> removeServer(guid, position)
|
||||||
|
else -> ownerActivity.toast("else")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "Error when sharing server", e)
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays QR code for the server configuration
|
||||||
|
* @param guid The server unique identifier
|
||||||
|
*/
|
||||||
|
private fun showQRCode(guid: String) {
|
||||||
|
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
|
||||||
|
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
|
||||||
|
if (share_method.isNotEmpty()) {
|
||||||
|
ivBinding.ivQcode.contentDescription = share_method[0]
|
||||||
|
} else {
|
||||||
|
ivBinding.ivQcode.contentDescription = "QR Code"
|
||||||
|
}
|
||||||
|
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shares server configuration to clipboard
|
||||||
|
* @param guid The server unique identifier
|
||||||
|
*/
|
||||||
|
private fun share2Clipboard(guid: String) {
|
||||||
|
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
|
||||||
|
ownerActivity.toastSuccess(R.string.toast_success)
|
||||||
|
} else {
|
||||||
|
ownerActivity.toastError(R.string.toast_failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shares full server configuration content to clipboard
|
||||||
|
* @param guid The server unique identifier
|
||||||
|
*/
|
||||||
|
private fun shareFullContent(guid: String) {
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
if (result == 0) {
|
||||||
|
ownerActivity.toastSuccess(R.string.toast_success)
|
||||||
|
} else {
|
||||||
|
ownerActivity.toastError(R.string.toast_failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits server configuration
|
||||||
|
* Opens appropriate editing interface based on configuration type
|
||||||
|
* @param guid The server unique identifier
|
||||||
|
* @param profile The server configuration
|
||||||
|
*/
|
||||||
|
private fun editServer(guid: String, profile: ProfileItem) {
|
||||||
|
val intent = Intent().putExtra("guid", guid)
|
||||||
|
.putExtra("isRunning", mainViewModel.isRunning.value)
|
||||||
|
.putExtra("createConfigType", profile.configType.value)
|
||||||
|
.putExtra("subscriptionId", subId)
|
||||||
|
when (profile.configType) {
|
||||||
|
EConfigType.CUSTOM -> {
|
||||||
|
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
EConfigType.POLICYGROUP -> {
|
||||||
|
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerGroupActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes server configuration
|
||||||
|
* Handles confirmation dialog and related checks
|
||||||
|
* @param guid The server unique identifier
|
||||||
|
* @param position The position in the list
|
||||||
|
*/
|
||||||
|
private fun removeServer(guid: String, position: Int) {
|
||||||
|
if (guid == MmkvManager.getSelectServer()) {
|
||||||
|
ownerActivity.toast(R.string.toast_action_not_allowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||||
|
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
removeServerSub(guid, position)
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||||
|
//do noting
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
removeServerSub(guid, position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the actual server removal process
|
||||||
|
* @param guid The server unique identifier
|
||||||
|
* @param position The position in the list
|
||||||
|
*/
|
||||||
|
private fun removeServerSub(guid: String, position: Int) {
|
||||||
|
mainViewModel.removeServer(guid)
|
||||||
|
adapter.removeServerSub(guid, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the selected server
|
||||||
|
* Updates UI and restarts service if needed
|
||||||
|
* @param guid The server unique identifier to select
|
||||||
|
*/
|
||||||
|
private fun setSelectServer(guid: String) {
|
||||||
|
val selected = MmkvManager.getSelectServer()
|
||||||
|
if (guid != selected) {
|
||||||
|
MmkvManager.setSelectServer(guid)
|
||||||
|
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
|
||||||
|
val toPosition = mainViewModel.getPosition(guid)
|
||||||
|
adapter.setSelectServer(fromPosition, toPosition)
|
||||||
|
|
||||||
|
if (mainViewModel.isRunning.value == true) {
|
||||||
|
ownerActivity.restartV2Ray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ActivityAdapterListener : MainAdapterListener {
|
||||||
|
override fun onEdit(guid: String, position: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShare(url: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRefreshData() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemove(guid: String, position: Int) {
|
||||||
|
removeServer(guid, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEdit(guid: String, position: Int, profile: ProfileItem) {
|
||||||
|
editServer(guid, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSelectServer(guid: String) {
|
||||||
|
setSelectServer(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
|
||||||
|
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
|
||||||
|
|
||||||
|
val (shareOptions, skip) = if (more) {
|
||||||
|
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
|
||||||
|
options to if (isCustom) 2 else 0
|
||||||
|
} else {
|
||||||
|
val options = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
|
||||||
|
options to if (isCustom) 2 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
shareServer(guid, profile, position, shareOptions, skip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRefresh() {
|
||||||
|
ownerActivity.importConfigViaSub()
|
||||||
|
binding.refreshLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to the currently selected server in the RecyclerView
|
||||||
|
*/
|
||||||
|
fun scrollToSelectedServer() {
|
||||||
|
val selectedGuid = MmkvManager.getSelectServer()
|
||||||
|
if (selectedGuid.isNullOrEmpty()) {
|
||||||
|
ownerActivity.toast(R.string.title_file_chooser)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the position of the selected server
|
||||||
|
val serversCache = mainViewModel.serversCache
|
||||||
|
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
|
||||||
|
val recyclerView = binding.recyclerView
|
||||||
|
|
||||||
|
if (position >= 0) {
|
||||||
|
// Get the layout manager
|
||||||
|
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
|
||||||
|
|
||||||
|
if (layoutManager != null) {
|
||||||
|
// Scroll to position with offset to center it on screen
|
||||||
|
// First scroll to position, then adjust to center
|
||||||
|
recyclerView.post {
|
||||||
|
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to smooth scroll if layout manager is not GridLayoutManager
|
||||||
|
recyclerView.smoothScrollToPosition(position)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ownerActivity.toast(R.string.toast_server_not_found_in_group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.v2ray.ang.enums.PermissionType
|
||||||
|
import com.v2ray.ang.helper.FileChooserHelper
|
||||||
|
import com.v2ray.ang.helper.PermissionHelper
|
||||||
|
import com.v2ray.ang.helper.QRCodeScannerHelper
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HelperBaseActivity extends BaseActivity and provides additional helpers for
|
||||||
|
* activities that need file chooser, permission requesting, or QR code scanning functionality.
|
||||||
|
*
|
||||||
|
* Activities that don't need these features should extend BaseActivity directly.
|
||||||
|
* Activities that need file selection, permissions, or QR code scanning should extend this class.
|
||||||
|
*
|
||||||
|
* Additional Responsibilities:
|
||||||
|
* - Provide file chooser helpers for selecting and creating files.
|
||||||
|
* - Provide permission request helpers with callbacks.
|
||||||
|
* - Provide QR code scanning helpers with camera permission handling.
|
||||||
|
*/
|
||||||
|
abstract class HelperBaseActivity : BaseActivity() {
|
||||||
|
private lateinit var fileChooser : FileChooserHelper
|
||||||
|
private lateinit var permissionRequester : PermissionHelper
|
||||||
|
private lateinit var qrCodeScanner : QRCodeScannerHelper
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
fileChooser = FileChooserHelper(this)
|
||||||
|
permissionRequester = PermissionHelper(this)
|
||||||
|
qrCodeScanner = QRCodeScannerHelper(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if permission is granted and request it if not.
|
||||||
|
* Convenience method that delegates to permissionRequester.
|
||||||
|
*
|
||||||
|
* @param permissionType The type of permission to check and request
|
||||||
|
* @param onGranted Callback to execute when permission is granted
|
||||||
|
*/
|
||||||
|
protected fun checkAndRequestPermission(
|
||||||
|
permissionType: PermissionType,
|
||||||
|
onGranted: () -> Unit
|
||||||
|
) {
|
||||||
|
permissionRequester.request(permissionType, onGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch file chooser with ACTION_GET_CONTENT intent.
|
||||||
|
* Convenience method that delegates to fileChooser helper.
|
||||||
|
*
|
||||||
|
* @param mimeType MIME type filter for files
|
||||||
|
* @param onResult Callback invoked with the selected file URI (null if cancelled)
|
||||||
|
*/
|
||||||
|
protected fun launchFileChooser(
|
||||||
|
mimeType: String = "*/*",
|
||||||
|
onResult: (Uri?) -> Unit
|
||||||
|
) {
|
||||||
|
checkAndRequestPermission(PermissionType.READ_STORAGE) {
|
||||||
|
fileChooser.launch(mimeType, onResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch document creator to create a new file at user-selected location.
|
||||||
|
* Convenience method that delegates to fileChooser helper.
|
||||||
|
* Note: No permission check needed as CreateDocument uses Storage Access Framework.
|
||||||
|
*
|
||||||
|
* @param fileName Default file name for the new document
|
||||||
|
* @param onResult Callback invoked with the created file URI (null if cancelled)
|
||||||
|
*/
|
||||||
|
protected fun launchCreateDocument(
|
||||||
|
fileName: String,
|
||||||
|
onResult: (Uri?) -> Unit
|
||||||
|
) {
|
||||||
|
fileChooser.createDocument(fileName, onResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch QR code scanner with camera permission check.
|
||||||
|
* Convenience method that delegates to qrCodeScanner helper.
|
||||||
|
*
|
||||||
|
* @param onResult Callback invoked with the scan result string (null if cancelled or failed)
|
||||||
|
*/
|
||||||
|
protected fun launchQRCodeScanner(onResult: (String?) -> Unit) {
|
||||||
|
checkAndRequestPermission(PermissionType.CAMERA) {
|
||||||
|
qrCodeScanner.launch(onResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,37 +2,34 @@ package com.v2ray.ang.ui
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import com.v2ray.ang.AppConfig
|
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityLogcatBinding
|
import com.v2ray.ang.databinding.ActivityLogcatBinding
|
||||||
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
|
|
||||||
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||||
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
|
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
|
||||||
|
private val viewModel: LogcatViewModel by viewModels()
|
||||||
private var logsetsAll: MutableList<String> = mutableListOf()
|
private lateinit var adapter: LogcatRecyclerAdapter
|
||||||
var logsets: MutableList<String> = mutableListOf()
|
|
||||||
private val adapter by lazy { LogcatRecyclerAdapter(this) }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
|
||||||
|
|
||||||
|
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
|
||||||
|
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||||
@@ -40,58 +37,12 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
|||||||
|
|
||||||
binding.refreshLayout.setOnRefreshListener(this)
|
binding.refreshLayout.setOnRefreshListener(this)
|
||||||
|
|
||||||
logsets.add(getString(R.string.pull_down_to_refresh))
|
toast(getString(R.string.pull_down_to_refresh))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLogcat() {
|
private fun onLogLongClick(log: String): Boolean {
|
||||||
|
Utils.setClipboard(this, log)
|
||||||
try {
|
return true
|
||||||
binding.refreshLayout.isRefreshing = true
|
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
val lst = LinkedHashSet<String>()
|
|
||||||
lst.add("logcat")
|
|
||||||
lst.add("-d")
|
|
||||||
lst.add("-v")
|
|
||||||
lst.add("time")
|
|
||||||
lst.add("-s")
|
|
||||||
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
|
|
||||||
val process = withContext(Dispatchers.IO) {
|
|
||||||
Runtime.getRuntime().exec(lst.toTypedArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
logsetsAll = allText.toMutableList()
|
|
||||||
logsets = allText.toMutableList()
|
|
||||||
refreshData()
|
|
||||||
binding.refreshLayout.isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to get logcat", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearLogcat() {
|
|
||||||
try {
|
|
||||||
lifecycleScope.launch(Dispatchers.Default) {
|
|
||||||
val lst = LinkedHashSet<String>()
|
|
||||||
lst.add("logcat")
|
|
||||||
lst.add("-c")
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val process = Runtime.getRuntime().exec(lst.toTypedArray())
|
|
||||||
process.waitFor()
|
|
||||||
}
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
logsetsAll.clear()
|
|
||||||
logsets.clear()
|
|
||||||
refreshData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to clear logcat", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
@@ -104,12 +55,14 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
|||||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||||
|
|
||||||
override fun onQueryTextChange(newText: String?): Boolean {
|
override fun onQueryTextChange(newText: String?): Boolean {
|
||||||
filterLogs(newText)
|
viewModel.filter(newText)
|
||||||
|
refreshData()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
searchView.setOnCloseListener {
|
searchView.setOnCloseListener {
|
||||||
filterLogs("")
|
viewModel.filter("")
|
||||||
|
refreshData()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,33 +72,33 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
|||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||||
R.id.copy_all -> {
|
R.id.copy_all -> {
|
||||||
Utils.setClipboard(this, logsets.joinToString("\n"))
|
val all = viewModel.getAll().joinToString("\n")
|
||||||
|
Utils.setClipboard(this, all)
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.clear_all -> {
|
R.id.clear_all -> {
|
||||||
clearLogcat()
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
viewModel.clearLogcat()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun filterLogs(content: String?): Boolean {
|
|
||||||
val key = content?.trim()
|
|
||||||
logsets = if (key.isNullOrEmpty()) {
|
|
||||||
logsetsAll.toMutableList()
|
|
||||||
} else {
|
|
||||||
logsetsAll.filter { it.contains(key) }.toMutableList()
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshData()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRefresh() {
|
override fun onRefresh() {
|
||||||
getLogcat()
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
viewModel.loadLogcat()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
binding.refreshLayout.isRefreshing = false
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ import android.view.ViewGroup
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
|
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||||
|
|
||||||
class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
|
class LogcatRecyclerAdapter(
|
||||||
private var mActivity: LogcatActivity = activity
|
private val viewModel: LogcatViewModel,
|
||||||
|
private val onLongClick: ((String) -> Boolean)? = null
|
||||||
|
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
|
||||||
|
|
||||||
|
override fun getItemCount() = viewModel.getAll().size
|
||||||
override fun getItemCount() = mActivity.logsets.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||||
try {
|
try {
|
||||||
val log = mActivity.logsets[position]
|
val logs = viewModel.getAll()
|
||||||
|
val log = logs[position]
|
||||||
|
|
||||||
if (log.isEmpty()) {
|
if (log.isEmpty()) {
|
||||||
holder.itemSubSettingBinding.logTag.text = ""
|
holder.itemSubSettingBinding.logTag.text = ""
|
||||||
holder.itemSubSettingBinding.logContent.text = ""
|
holder.itemSubSettingBinding.logContent.text = ""
|
||||||
@@ -27,8 +30,7 @@ class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter
|
|||||||
}
|
}
|
||||||
|
|
||||||
holder.itemView.setOnLongClickListener {
|
holder.itemView.setOnLongClickListener {
|
||||||
Utils.setClipboard(mActivity, log)
|
onLongClick?.invoke(log) ?: false
|
||||||
true
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Error binding log view data", e)
|
Log.e(AppConfig.TAG, "Error binding log view data", e)
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.res.ColorStateList
|
import android.content.res.ColorStateList
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@@ -25,15 +22,16 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import com.google.android.material.navigation.NavigationView
|
import com.google.android.material.navigation.NavigationView
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.VPN
|
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityMainBinding
|
import com.v2ray.ang.databinding.ActivityMainBinding
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.PermissionType
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.handler.V2RayServiceManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.MainViewModel
|
import com.v2ray.ang.viewmodel.MainViewModel
|
||||||
@@ -42,7 +40,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener {
|
class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
|
||||||
private val binding by lazy {
|
private val binding by lazy {
|
||||||
ActivityMainBinding.inflate(layoutInflater)
|
ActivityMainBinding.inflate(layoutInflater)
|
||||||
}
|
}
|
||||||
@@ -65,52 +63,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// register activity result for requesting permission
|
|
||||||
private val requestPermissionLauncher =
|
|
||||||
registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { isGranted: Boolean ->
|
|
||||||
if (isGranted) {
|
|
||||||
when (pendingAction) {
|
|
||||||
Action.IMPORT_QR_CODE_CONFIG ->
|
|
||||||
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
|
|
||||||
|
|
||||||
Action.READ_CONTENT_FROM_URI ->
|
|
||||||
chooseFileForCustomConfig.launch(Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply {
|
|
||||||
type = "*/*"
|
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
}, getString(R.string.title_file_chooser)))
|
|
||||||
|
|
||||||
Action.POST_NOTIFICATIONS -> {}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast(R.string.toast_permission_denied)
|
|
||||||
}
|
|
||||||
pendingAction = Action.NONE
|
|
||||||
}
|
|
||||||
|
|
||||||
private var pendingAction: Action = Action.NONE
|
|
||||||
|
|
||||||
enum class Action {
|
|
||||||
NONE,
|
|
||||||
IMPORT_QR_CODE_CONFIG,
|
|
||||||
READ_CONTENT_FROM_URI,
|
|
||||||
POST_NOTIFICATIONS
|
|
||||||
}
|
|
||||||
|
|
||||||
private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
val uri = it.data?.data
|
|
||||||
if (it.resultCode == RESULT_OK && uri != null) {
|
|
||||||
readContentFromUri(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val scanQRCodeForConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
if (it.resultCode == RESULT_OK) {
|
|
||||||
importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -142,17 +94,14 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
|||||||
})
|
})
|
||||||
|
|
||||||
binding.fab.setOnClickListener { handleFabAction() }
|
binding.fab.setOnClickListener { handleFabAction() }
|
||||||
|
binding.fabLocate.setOnClickListener { locateSelectedServer() }
|
||||||
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
|
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
|
||||||
|
|
||||||
setupGroupTab()
|
setupGroupTab()
|
||||||
setupViewModel()
|
setupViewModel()
|
||||||
mainViewModel.reloadServerList()
|
mainViewModel.reloadServerList()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
pendingAction = Action.POST_NOTIFICATIONS
|
|
||||||
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +137,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
|||||||
|
|
||||||
if (mainViewModel.isRunning.value == true) {
|
if (mainViewModel.isRunning.value == true) {
|
||||||
V2RayServiceManager.stopVService(this)
|
V2RayServiceManager.stopVService(this)
|
||||||
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
|
} else if (SettingsManager.isVpnMode()) {
|
||||||
val intent = VpnService.prepare(this)
|
val intent = VpnService.prepare(this)
|
||||||
if (intent == null) {
|
if (intent == null) {
|
||||||
startV2Ray()
|
startV2Ray()
|
||||||
@@ -416,12 +365,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
|||||||
* import config from qrcode
|
* import config from qrcode
|
||||||
*/
|
*/
|
||||||
private fun importQRcode(): Boolean {
|
private fun importQRcode(): Boolean {
|
||||||
val permission = Manifest.permission.CAMERA
|
launchQRCodeScanner { scanResult ->
|
||||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
if (scanResult != null) {
|
||||||
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
|
importBatchConfig(scanResult)
|
||||||
} else {
|
}
|
||||||
pendingAction = Action.IMPORT_QR_CODE_CONFIG
|
|
||||||
requestPermissionLauncher.launch(permission)
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -487,18 +434,27 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
|||||||
/**
|
/**
|
||||||
* import config from sub
|
* import config from sub
|
||||||
*/
|
*/
|
||||||
private fun importConfigViaSub(): Boolean {
|
fun importConfigViaSub(): Boolean {
|
||||||
showLoading()
|
showLoading()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val count = mainViewModel.updateConfigViaSubAll()
|
val result = mainViewModel.updateConfigViaSubAll()
|
||||||
delay(500L)
|
delay(500L)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
if (count > 0) {
|
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||||
toast(getString(R.string.title_update_config_count, count))
|
toast(R.string.title_update_subscription_no_subscription)
|
||||||
mainViewModel.reloadServerList()
|
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||||
|
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||||
} else {
|
} else {
|
||||||
toastError(R.string.toast_failure)
|
toast(
|
||||||
|
getString(
|
||||||
|
R.string.title_update_subscription_result,
|
||||||
|
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (result.configCount > 0) {
|
||||||
|
mainViewModel.reloadServerList()
|
||||||
}
|
}
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
@@ -592,21 +548,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
|||||||
* show file chooser
|
* show file chooser
|
||||||
*/
|
*/
|
||||||
private fun showFileChooser() {
|
private fun showFileChooser() {
|
||||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
launchFileChooser { uri ->
|
||||||
intent.type = "*/*"
|
if (uri == null) {
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
return@launchFileChooser
|
||||||
|
}
|
||||||
|
|
||||||
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
readContentFromUri(uri)
|
||||||
Manifest.permission.READ_MEDIA_IMAGES
|
|
||||||
} else {
|
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
pendingAction = Action.READ_CONTENT_FROM_URI
|
|
||||||
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
|
|
||||||
} else {
|
|
||||||
requestPermissionLauncher.launch(permission)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,22 +561,53 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
|||||||
* read content from uri
|
* read content from uri
|
||||||
*/
|
*/
|
||||||
private fun readContentFromUri(uri: Uri) {
|
private fun readContentFromUri(uri: Uri) {
|
||||||
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
try {
|
||||||
Manifest.permission.READ_MEDIA_IMAGES
|
contentResolver.openInputStream(uri).use { input ->
|
||||||
} else {
|
importBatchConfig(input?.bufferedReader()?.readText())
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.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
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
val targetGroupIndex = groupPagerAdapter.groups.indexOfFirst { it.id == targetSubscriptionId }
|
||||||
try {
|
if (targetGroupIndex < 0) {
|
||||||
contentResolver.openInputStream(uri).use { input ->
|
toast(R.string.toast_server_not_found_in_group)
|
||||||
importBatchConfig(input?.bufferedReader()?.readText())
|
return
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
|
// 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 {
|
} else {
|
||||||
requestPermissionLauncher.launch(permission)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +1,41 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.text.TextUtils
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.v2ray.ang.AngApplication.Companion.application
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
import com.v2ray.ang.contracts.MainAdapterListener
|
||||||
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
|
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
|
||||||
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
|
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.ServersCache
|
import com.v2ray.ang.dto.ServersCache
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.extension.toastError
|
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.v2ray.ang.viewmodel.MainViewModel
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
|
|
||||||
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
|
class MainRecyclerAdapter(
|
||||||
|
private val mainViewModel: MainViewModel,
|
||||||
|
private val adapterListener: MainAdapterListener?
|
||||||
|
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
|
||||||
companion object {
|
companion object {
|
||||||
private const val VIEW_TYPE_ITEM = 1
|
private const val VIEW_TYPE_ITEM = 1
|
||||||
private const val VIEW_TYPE_FOOTER = 2
|
private const val VIEW_TYPE_FOOTER = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mActivity: MainActivity = activity
|
|
||||||
private val share_method: Array<out String> by lazy {
|
|
||||||
mActivity.resources.getStringArray(R.array.share_method)
|
|
||||||
}
|
|
||||||
private val share_method_more: Array<out String> by lazy {
|
|
||||||
mActivity.resources.getStringArray(R.array.share_method_more)
|
|
||||||
}
|
|
||||||
var isRunning = false
|
|
||||||
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
|
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
|
||||||
private var data: MutableList<ServersCache> = mutableListOf()
|
private var data: MutableList<ServersCache> = mutableListOf()
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
|
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
|
||||||
if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
|
|
||||||
mActivity.runOnUiThread { setData(newData, position) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data = newData?.toMutableList() ?: mutableListOf()
|
data = newData?.toMutableList() ?: mutableListOf()
|
||||||
|
|
||||||
if (position >= 0 && position in data.indices) {
|
if (position >= 0 && position in data.indices) {
|
||||||
@@ -68,9 +49,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
||||||
if (holder is MainViewHolder) {
|
if (holder is MainViewHolder) {
|
||||||
|
val context = holder.itemMainBinding.root.context
|
||||||
val guid = data[position].guid
|
val guid = data[position].guid
|
||||||
val profile = data[position].profile
|
val profile = data[position].profile
|
||||||
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
|
|
||||||
|
|
||||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||||
|
|
||||||
@@ -83,9 +64,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
|||||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||||
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
|
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
|
||||||
if ((aff?.testDelayMillis ?: 0L) < 0L) {
|
if ((aff?.testDelayMillis ?: 0L) < 0L) {
|
||||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPingRed))
|
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
|
||||||
} else {
|
} else {
|
||||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing))
|
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
|
||||||
}
|
}
|
||||||
|
|
||||||
//layoutIndicator
|
//layoutIndicator
|
||||||
@@ -107,11 +88,8 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
|||||||
holder.itemMainBinding.layoutRemove.visibility = View.GONE
|
holder.itemMainBinding.layoutRemove.visibility = View.GONE
|
||||||
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
|
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
|
||||||
|
|
||||||
//share method
|
|
||||||
val shareOptions = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
|
|
||||||
|
|
||||||
holder.itemMainBinding.layoutMore.setOnClickListener {
|
holder.itemMainBinding.layoutMore.setOnClickListener {
|
||||||
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
|
adapterListener?.onShare(guid, profile, position, true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
|
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
|
||||||
@@ -119,34 +97,23 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
|||||||
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
|
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
|
||||||
holder.itemMainBinding.layoutMore.visibility = View.GONE
|
holder.itemMainBinding.layoutMore.visibility = View.GONE
|
||||||
|
|
||||||
//share method
|
|
||||||
val shareOptions = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
|
|
||||||
|
|
||||||
holder.itemMainBinding.layoutShare.setOnClickListener {
|
holder.itemMainBinding.layoutShare.setOnClickListener {
|
||||||
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
|
adapterListener?.onShare(guid, profile, position, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.itemMainBinding.layoutEdit.setOnClickListener {
|
holder.itemMainBinding.layoutEdit.setOnClickListener {
|
||||||
editServer(guid, profile)
|
adapterListener?.onEdit(guid, position, profile)
|
||||||
}
|
}
|
||||||
holder.itemMainBinding.layoutRemove.setOnClickListener {
|
holder.itemMainBinding.layoutRemove.setOnClickListener {
|
||||||
removeServer(guid, position)
|
adapterListener?.onRemove(guid, position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.itemMainBinding.infoContainer.setOnClickListener {
|
holder.itemMainBinding.infoContainer.setOnClickListener {
|
||||||
setSelectServer(guid)
|
adapterListener?.onSelectServer(guid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if (holder is FooterViewHolder) {
|
|
||||||
// if (true) {
|
|
||||||
// holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
|
|
||||||
// } else {
|
|
||||||
// holder.itemFooterBinding.layoutEdit.setOnClickListener {
|
|
||||||
// Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,19 +123,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
|||||||
* @return Formatted address string
|
* @return Formatted address string
|
||||||
*/
|
*/
|
||||||
private fun getAddress(profile: ProfileItem): String {
|
private fun getAddress(profile: ProfileItem): String {
|
||||||
// Hide xxx:xxx:***/xxx.xxx.xxx.***
|
return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
|
||||||
val server = profile.server
|
|
||||||
val port = profile.serverPort
|
|
||||||
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
|
|
||||||
|
|
||||||
val addrPart = server?.let {
|
|
||||||
if (it.contains(":"))
|
|
||||||
it.split(":").take(2).joinToString(":", postfix = ":***")
|
|
||||||
else
|
|
||||||
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
|
|
||||||
} ?: ""
|
|
||||||
|
|
||||||
return "$addrPart : ${port ?: ""}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,135 +133,14 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
|||||||
*/
|
*/
|
||||||
private fun getSubscriptionRemarks(profile: ProfileItem): String {
|
private fun getSubscriptionRemarks(profile: ProfileItem): String {
|
||||||
val subRemarks =
|
val subRemarks =
|
||||||
if (mActivity.mainViewModel.subscriptionId.isEmpty())
|
if (mainViewModel.subscriptionId.isEmpty())
|
||||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
||||||
else
|
else
|
||||||
null
|
null
|
||||||
return subRemarks?.toString() ?: ""
|
return subRemarks?.toString() ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun removeServerSub(guid: String, position: Int) {
|
||||||
* Shares server configuration
|
|
||||||
* 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(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
|
|
||||||
try {
|
|
||||||
when (i + skip) {
|
|
||||||
0 -> showQRCode(guid)
|
|
||||||
1 -> share2Clipboard(guid)
|
|
||||||
2 -> shareFullContent(guid)
|
|
||||||
3 -> editServer(guid, profile)
|
|
||||||
4 -> removeServer(guid, position)
|
|
||||||
else -> mActivity.toast("else")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Error when sharing server", e)
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays QR code for the server configuration
|
|
||||||
* @param guid The server unique identifier
|
|
||||||
*/
|
|
||||||
private fun showQRCode(guid: String) {
|
|
||||||
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
|
|
||||||
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
|
|
||||||
if (share_method.isNotEmpty()) {
|
|
||||||
ivBinding.ivQcode.contentDescription = share_method[0]
|
|
||||||
} else {
|
|
||||||
ivBinding.ivQcode.contentDescription = "QR Code"
|
|
||||||
}
|
|
||||||
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shares server configuration to clipboard
|
|
||||||
* @param guid The server unique identifier
|
|
||||||
*/
|
|
||||||
private fun share2Clipboard(guid: String) {
|
|
||||||
if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
|
|
||||||
mActivity.toastSuccess(R.string.toast_success)
|
|
||||||
} else {
|
|
||||||
mActivity.toastError(R.string.toast_failure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shares full server configuration content to clipboard
|
|
||||||
* @param guid The server unique identifier
|
|
||||||
*/
|
|
||||||
private fun shareFullContent(guid: String) {
|
|
||||||
mActivity.lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
val result = AngConfigManager.shareFullContent2Clipboard(mActivity, guid)
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
if (result == 0) {
|
|
||||||
mActivity.toastSuccess(R.string.toast_success)
|
|
||||||
} else {
|
|
||||||
mActivity.toastError(R.string.toast_failure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edits server configuration
|
|
||||||
* Opens appropriate editing interface based on configuration type
|
|
||||||
* @param guid The server unique identifier
|
|
||||||
* @param profile The server configuration
|
|
||||||
*/
|
|
||||||
private fun editServer(guid: String, profile: ProfileItem) {
|
|
||||||
val intent = Intent().putExtra("guid", guid)
|
|
||||||
.putExtra("isRunning", isRunning)
|
|
||||||
.putExtra("createConfigType", profile.configType.value)
|
|
||||||
if (profile.configType == EConfigType.CUSTOM) {
|
|
||||||
mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
|
|
||||||
} else if (profile.configType == EConfigType.POLICYGROUP) {
|
|
||||||
mActivity.startActivity(intent.setClass(mActivity, ServerGroupActivity::class.java))
|
|
||||||
} else {
|
|
||||||
mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes server configuration
|
|
||||||
* Handles confirmation dialog and related checks
|
|
||||||
* @param guid The server unique identifier
|
|
||||||
* @param position The position in the list
|
|
||||||
*/
|
|
||||||
private fun removeServer(guid: String, position: Int) {
|
|
||||||
if (guid == MmkvManager.getSelectServer()) {
|
|
||||||
application.toast(R.string.toast_action_not_allowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
|
||||||
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
removeServerSub(guid, position)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
|
||||||
//do noting
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
} else {
|
|
||||||
removeServerSub(guid, position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes the actual server removal process
|
|
||||||
* @param guid The server unique identifier
|
|
||||||
* @param position The position in the list
|
|
||||||
*/
|
|
||||||
private fun removeServerSub(guid: String, position: Int) {
|
|
||||||
mActivity.mainViewModel.removeServer(guid)
|
|
||||||
val idx = data.indexOfFirst { it.guid == guid }
|
val idx = data.indexOfFirst { it.guid == guid }
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
data.removeAt(idx)
|
data.removeAt(idx)
|
||||||
@@ -315,23 +149,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun setSelectServer(fromPosition: Int, toPosition: Int) {
|
||||||
* Sets the selected server
|
notifyItemChanged(fromPosition)
|
||||||
* Updates UI and restarts service if needed
|
notifyItemChanged(toPosition)
|
||||||
* @param guid The server unique identifier to select
|
|
||||||
*/
|
|
||||||
private fun setSelectServer(guid: String) {
|
|
||||||
val selected = MmkvManager.getSelectServer()
|
|
||||||
if (guid != selected) {
|
|
||||||
MmkvManager.setSelectServer(guid)
|
|
||||||
if (!TextUtils.isEmpty(selected)) {
|
|
||||||
notifyItemChanged(mActivity.mainViewModel.getPosition(selected.orEmpty()))
|
|
||||||
}
|
|
||||||
notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
|
|
||||||
if (isRunning) {
|
|
||||||
mActivity.restartV2Ray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
|
||||||
@@ -369,7 +189,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
|||||||
BaseViewHolder(itemFooterBinding.root)
|
BaseViewHolder(itemFooterBinding.root)
|
||||||
|
|
||||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||||
mActivity.mainViewModel.swapServer(fromPosition, toPosition)
|
mainViewModel.swapServer(fromPosition, toPosition)
|
||||||
if (fromPosition < data.size && toPosition < data.size) {
|
if (fromPosition < data.size && toPosition < data.size) {
|
||||||
Collections.swap(data, fromPosition, toPosition)
|
Collections.swap(data, fromPosition, toPosition)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class PerAppProxyActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
appsAll = apps
|
appsAll = apps
|
||||||
adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, viewModel)
|
adapter = PerAppProxyAdapter(apps, viewModel)
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -292,7 +292,7 @@ class PerAppProxyActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
adapter = PerAppProxyAdapter(this, apps, adapter?.viewModel ?: viewModel)
|
adapter = PerAppProxyAdapter(apps, adapter?.viewModel ?: viewModel)
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
refreshData()
|
refreshData()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
|||||||
import com.v2ray.ang.dto.AppInfo
|
import com.v2ray.ang.dto.AppInfo
|
||||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||||
|
|
||||||
class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, val viewModel: PerAppProxyViewModel) :
|
class PerAppProxyAdapter(
|
||||||
RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
val apps: List<AppInfo>,
|
||||||
|
val viewModel: PerAppProxyViewModel
|
||||||
|
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val VIEW_TYPE_HEADER = 0
|
private const val VIEW_TYPE_HEADER = 0
|
||||||
@@ -37,6 +39,7 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, va
|
|||||||
)
|
)
|
||||||
BaseViewHolder(view)
|
BaseViewHolder(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
|
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
|
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
import com.v2ray.ang.dto.RulesetItem
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
@@ -62,14 +63,11 @@ class RoutingEditActivity : BaseActivity() {
|
|||||||
rulesetItem.apply {
|
rulesetItem.apply {
|
||||||
remarks = binding.etRemarks.text.toString()
|
remarks = binding.etRemarks.text.toString()
|
||||||
locked = binding.chkLocked.isChecked
|
locked = binding.chkLocked.isChecked
|
||||||
domain = binding.etDomain.text.toString().takeIf { it.isNotEmpty() }
|
domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||||
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||||
ip = binding.etIp.text.toString().takeIf { it.isNotEmpty() }
|
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||||
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
port = binding.etPort.text.toString().nullIfBlank()
|
||||||
protocol = binding.etProtocol.text.toString().takeIf { it.isNotEmpty() }
|
network = binding.etNetwork.text.toString().nullIfBlank()
|
||||||
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
|
||||||
port = binding.etPort.text.toString().takeIf { it.isNotEmpty() }
|
|
||||||
network = binding.etNetwork.text.toString().takeIf { it.isNotEmpty() }
|
|
||||||
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
|
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -15,8 +13,8 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||||
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
|
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
|
||||||
import com.v2ray.ang.extension.toast
|
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
@@ -29,9 +27,10 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class RoutingSettingActivity : BaseActivity() {
|
class RoutingSettingActivity : HelperBaseActivity() {
|
||||||
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
|
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
|
||||||
|
private val ownerActivity: RoutingSettingActivity
|
||||||
|
get() = this
|
||||||
private val viewModel: RoutingSettingsViewModel by viewModels()
|
private val viewModel: RoutingSettingsViewModel by viewModels()
|
||||||
private lateinit var adapter: RoutingSettingRecyclerAdapter
|
private lateinit var adapter: RoutingSettingRecyclerAdapter
|
||||||
private var mItemTouchHelper: ItemTouchHelper? = null
|
private var mItemTouchHelper: ItemTouchHelper? = null
|
||||||
@@ -42,22 +41,12 @@ class RoutingSettingActivity : BaseActivity() {
|
|||||||
resources.getStringArray(R.array.preset_rulesets)
|
resources.getStringArray(R.array.preset_rulesets)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val requestCameraPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { isGranted: Boolean ->
|
|
||||||
if (isGranted) {
|
|
||||||
scanQRcodeForRulesets.launch(Intent(this, ScannerActivity::class.java))
|
|
||||||
} else {
|
|
||||||
toast(R.string.toast_permission_denied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
//setContentView(binding.root)
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
|
||||||
|
|
||||||
adapter = RoutingSettingRecyclerAdapter(this, viewModel)
|
adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
|
||||||
|
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||||
@@ -87,7 +76,7 @@ class RoutingSettingActivity : BaseActivity() {
|
|||||||
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
|
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
|
||||||
R.id.import_predefined_rulesets -> importPredefined().let { true }
|
R.id.import_predefined_rulesets -> importPredefined().let { true }
|
||||||
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
|
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
|
||||||
R.id.import_rulesets_from_qrcode -> requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA).let { true }
|
R.id.import_rulesets_from_qrcode -> importQRcode()
|
||||||
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
|
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
@@ -159,6 +148,15 @@ class RoutingSettingActivity : BaseActivity() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun importQRcode(): Boolean {
|
||||||
|
launchQRCodeScanner { scanResult ->
|
||||||
|
if (scanResult != null) {
|
||||||
|
importRulesetsFromQRcode(scanResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private fun export2Clipboard() {
|
private fun export2Clipboard() {
|
||||||
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
||||||
if (rulesetList.isNullOrEmpty()) {
|
if (rulesetList.isNullOrEmpty()) {
|
||||||
@@ -169,11 +167,6 @@ class RoutingSettingActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val scanQRcodeForRulesets = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
if (it.resultCode == RESULT_OK) {
|
|
||||||
importRulesetsFromQRcode(it.data?.getStringExtra("SCAN_RESULT"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun importRulesetsFromQRcode(qrcode: String?): Boolean {
|
private fun importRulesetsFromQRcode(qrcode: String?): Boolean {
|
||||||
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
|
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
|
||||||
@@ -202,4 +195,23 @@ class RoutingSettingActivity : BaseActivity() {
|
|||||||
viewModel.reload()
|
viewModel.reload()
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||||
|
override fun onEdit(guid: String, position: Int) {
|
||||||
|
startActivity(
|
||||||
|
Intent(ownerActivity, RoutingEditActivity::class.java)
|
||||||
|
.putExtra("position", position)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemove(guid: String, position: Int) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShare(url: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRefreshData() {
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||||
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
|
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
|
||||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||||
|
|
||||||
class RoutingSettingRecyclerAdapter(
|
class RoutingSettingRecyclerAdapter(
|
||||||
val activity: RoutingSettingActivity,
|
private val viewModel: RoutingSettingsViewModel,
|
||||||
private val viewModel: RoutingSettingsViewModel
|
private val adapterListener: BaseAdapterListener?
|
||||||
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
|
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
|
||||||
ItemTouchHelperAdapter {
|
ItemTouchHelperAdapter {
|
||||||
|
|
||||||
private var mActivity: RoutingSettingActivity = activity
|
|
||||||
override fun getItemCount() = viewModel.getAll().size
|
override fun getItemCount() = viewModel.getAll().size
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||||
@@ -33,10 +32,7 @@ class RoutingSettingRecyclerAdapter(
|
|||||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||||
|
|
||||||
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
|
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
|
||||||
mActivity.startActivity(
|
adapterListener?.onEdit("", position)
|
||||||
Intent(mActivity, RoutingEditActivity::class.java)
|
|
||||||
.putExtra("position", position)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||||
@@ -76,7 +72,7 @@ class RoutingSettingRecyclerAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemMoveCompleted() {
|
override fun onItemMoveCompleted() {
|
||||||
mActivity.refreshData()
|
adapterListener?.onRefreshData()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemDismiss(position: Int) {
|
override fun onItemDismiss(position: Int) {
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.extension.toast
|
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
|
|
||||||
class ScScannerActivity : BaseActivity() {
|
class ScScannerActivity : HelperBaseActivity() {
|
||||||
|
|
||||||
private val requestCameraPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { isGranted: Boolean ->
|
|
||||||
if (isGranted) {
|
|
||||||
scanQRCode.launch(Intent(this, ScannerActivity::class.java))
|
|
||||||
} else {
|
|
||||||
toast(R.string.toast_permission_denied)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -29,24 +15,20 @@ class ScScannerActivity : BaseActivity() {
|
|||||||
importQRcode()
|
importQRcode()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importQRcode(): Boolean {
|
private fun importQRcode() {
|
||||||
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
launchQRCodeScanner { scanResult ->
|
||||||
return true
|
if (scanResult != null) {
|
||||||
}
|
val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
|
||||||
|
|
||||||
private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
if (count + countSub > 0) {
|
||||||
if (it.resultCode == RESULT_OK) {
|
toastSuccess(R.string.toast_success)
|
||||||
val scanResult = it.data?.getStringExtra("SCAN_RESULT").orEmpty()
|
} else {
|
||||||
val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
|
toastError(R.string.toast_failure)
|
||||||
|
}
|
||||||
|
|
||||||
if (count + countSub > 0) {
|
startActivity(Intent(this, MainActivity::class.java))
|
||||||
toastSuccess(R.string.toast_success)
|
|
||||||
} else {
|
|
||||||
toastError(R.string.toast_failure)
|
|
||||||
}
|
}
|
||||||
|
finish()
|
||||||
startActivity(Intent(this, MainActivity::class.java))
|
|
||||||
}
|
}
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityNoneBinding
|
import com.v2ray.ang.databinding.ActivityNoneBinding
|
||||||
@@ -19,43 +14,13 @@ import com.v2ray.ang.handler.MmkvManager
|
|||||||
import com.v2ray.ang.util.QRCodeDecoder
|
import com.v2ray.ang.util.QRCodeDecoder
|
||||||
import io.github.g00fy2.quickie.QRResult
|
import io.github.g00fy2.quickie.QRResult
|
||||||
import io.github.g00fy2.quickie.ScanCustomCode
|
import io.github.g00fy2.quickie.ScanCustomCode
|
||||||
|
import io.github.g00fy2.quickie.config.BarcodeFormat
|
||||||
import io.github.g00fy2.quickie.config.ScannerConfig
|
import io.github.g00fy2.quickie.config.ScannerConfig
|
||||||
|
|
||||||
class ScannerActivity : BaseActivity() {
|
class ScannerActivity : HelperBaseActivity() {
|
||||||
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
|
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
|
||||||
|
|
||||||
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
|
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
|
||||||
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
val uri = it.data?.data
|
|
||||||
if (it.resultCode == RESULT_OK && uri != null) {
|
|
||||||
try {
|
|
||||||
val inputStream = contentResolver.openInputStream(uri)
|
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
|
||||||
inputStream?.close()
|
|
||||||
|
|
||||||
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
|
|
||||||
if (text.isNullOrEmpty()) {
|
|
||||||
toast(R.string.toast_decoding_failed)
|
|
||||||
} else {
|
|
||||||
finished(text)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
|
|
||||||
toast(R.string.toast_decoding_failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val requestPermissionLauncher =
|
|
||||||
registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { isGranted: Boolean ->
|
|
||||||
if (isGranted) {
|
|
||||||
showFileChooser()
|
|
||||||
} else {
|
|
||||||
toast(R.string.toast_permission_denied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -73,6 +38,7 @@ class ScannerActivity : BaseActivity() {
|
|||||||
setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
|
setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
|
||||||
setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
|
setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
|
||||||
setShowCloseButton(true) // show or hide (default) close button
|
setShowCloseButton(true) // show or hide (default) close button
|
||||||
|
setBarcodeFormats(listOf(BarcodeFormat.QR_CODE))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,17 +70,7 @@ class ScannerActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.select_photo -> {
|
R.id.select_photo -> {
|
||||||
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
showFileChooser()
|
||||||
Manifest.permission.READ_MEDIA_IMAGES
|
|
||||||
} else {
|
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
showFileChooser()
|
|
||||||
} else {
|
|
||||||
requestPermissionLauncher.launch(permission)
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,15 +79,25 @@ class ScannerActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showFileChooser() {
|
private fun showFileChooser() {
|
||||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
launchFileChooser("image/*") { uri ->
|
||||||
intent.type = "image/*"
|
if (uri == null) {
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
return@launchFileChooser
|
||||||
//intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
}
|
||||||
|
try {
|
||||||
|
val inputStream = contentResolver.openInputStream(uri)
|
||||||
|
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
|
inputStream?.close()
|
||||||
|
|
||||||
try {
|
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
|
||||||
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
|
if (text.isNullOrEmpty()) {
|
||||||
} catch (ex: android.content.ActivityNotFoundException) {
|
toast(R.string.toast_decoding_failed)
|
||||||
toast(R.string.toast_require_file_manager)
|
} else {
|
||||||
|
finished(text)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
|
||||||
|
toast(R.string.toast_decoding_failed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,12 +20,13 @@ import com.v2ray.ang.AppConfig.TLS
|
|||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
|
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
@@ -128,7 +129,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) }
|
private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) }
|
||||||
private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) }
|
private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) }
|
||||||
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
|
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
|
||||||
private val et_pinsha256: EditText? by lazy { findViewById(R.id.et_pinsha256) }
|
|
||||||
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
|
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
|
||||||
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
|
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
|
||||||
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
|
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
|
||||||
@@ -137,6 +137,8 @@ class ServerActivity : BaseActivity() {
|
|||||||
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
|
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
|
||||||
private val sp_ech_force_query: Spinner? by lazy { findViewById(R.id.sp_ech_force_query) }
|
private val sp_ech_force_query: Spinner? by lazy { findViewById(R.id.sp_ech_force_query) }
|
||||||
private val container_ech_force_query: LinearLayout? by lazy { findViewById(R.id.lay_ech_force_query) }
|
private val container_ech_force_query: LinearLayout? by lazy { findViewById(R.id.lay_ech_force_query) }
|
||||||
|
private val et_pinned_ca256: EditText? by lazy { findViewById(R.id.et_pinned_ca256) }
|
||||||
|
private val container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) }
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -274,7 +276,8 @@ class ServerActivity : BaseActivity() {
|
|||||||
container_spider_x,
|
container_spider_x,
|
||||||
container_mldsa65_verify,
|
container_mldsa65_verify,
|
||||||
container_ech_config_list,
|
container_ech_config_list,
|
||||||
container_ech_force_query
|
container_ech_force_query,
|
||||||
|
container_pinned_ca256
|
||||||
).forEach { it?.visibility = View.GONE }
|
).forEach { it?.visibility = View.GONE }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +289,8 @@ class ServerActivity : BaseActivity() {
|
|||||||
container_alpn,
|
container_alpn,
|
||||||
container_allow_insecure,
|
container_allow_insecure,
|
||||||
container_ech_config_list,
|
container_ech_config_list,
|
||||||
container_ech_force_query
|
container_ech_force_query,
|
||||||
|
container_pinned_ca256
|
||||||
).forEach { it?.visibility = View.VISIBLE }
|
).forEach { it?.visibility = View.VISIBLE }
|
||||||
listOf(
|
listOf(
|
||||||
container_public_key,
|
container_public_key,
|
||||||
@@ -306,7 +310,8 @@ class ServerActivity : BaseActivity() {
|
|||||||
container_alpn,
|
container_alpn,
|
||||||
container_allow_insecure,
|
container_allow_insecure,
|
||||||
container_ech_config_list,
|
container_ech_config_list,
|
||||||
container_ech_force_query
|
container_ech_force_query,
|
||||||
|
container_pinned_ca256
|
||||||
).forEach { it?.visibility = View.GONE }
|
).forEach { it?.visibility = View.GONE }
|
||||||
listOf(
|
listOf(
|
||||||
container_public_key,
|
container_public_key,
|
||||||
@@ -361,7 +366,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
et_obfs_password?.text = Utils.getEditable(config.obfsPassword)
|
et_obfs_password?.text = Utils.getEditable(config.obfsPassword)
|
||||||
et_port_hop?.text = Utils.getEditable(config.portHopping)
|
et_port_hop?.text = Utils.getEditable(config.portHopping)
|
||||||
et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval)
|
et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval)
|
||||||
et_pinsha256?.text = Utils.getEditable(config.pinSHA256)
|
|
||||||
et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown)
|
et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown)
|
||||||
et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp)
|
et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp)
|
||||||
}
|
}
|
||||||
@@ -394,6 +398,7 @@ class ServerActivity : BaseActivity() {
|
|||||||
val index = Utils.arrayFind(echForceQuerys, it)
|
val index = Utils.arrayFind(echForceQuerys, it)
|
||||||
index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) }
|
index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) }
|
||||||
}
|
}
|
||||||
|
et_pinned_ca256?.text = Utils.getEditable(config.pinnedCA256)
|
||||||
} else if (config.security == REALITY) {
|
} else if (config.security == REALITY) {
|
||||||
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
|
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
|
||||||
et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
|
et_short_id?.text = Utils.getEditable(config.shortId.orEmpty())
|
||||||
@@ -488,6 +493,8 @@ class ServerActivity : BaseActivity() {
|
|||||||
saveStreamSettings(config)
|
saveStreamSettings(config)
|
||||||
saveTls(config)
|
saveTls(config)
|
||||||
|
|
||||||
|
config.description = AngConfigManager.generateDescription(config)
|
||||||
|
|
||||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||||
config.subscriptionId = subscriptionId.orEmpty()
|
config.subscriptionId = subscriptionId.orEmpty()
|
||||||
}
|
}
|
||||||
@@ -527,7 +534,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
config.obfsPassword = et_obfs_password?.text?.toString()
|
config.obfsPassword = et_obfs_password?.text?.toString()
|
||||||
config.portHopping = et_port_hop?.text?.toString()
|
config.portHopping = et_port_hop?.text?.toString()
|
||||||
config.portHoppingInterval = et_port_hop_interval?.text?.toString()
|
config.portHoppingInterval = et_port_hop_interval?.text?.toString()
|
||||||
config.pinSHA256 = et_pinsha256?.text?.toString()
|
|
||||||
config.bandwidthDown = et_bandwidth_down?.text?.toString()
|
config.bandwidthDown = et_bandwidth_down?.text?.toString()
|
||||||
config.bandwidthUp = et_bandwidth_up?.text?.toString()
|
config.bandwidthUp = et_bandwidth_up?.text?.toString()
|
||||||
}
|
}
|
||||||
@@ -566,6 +572,7 @@ class ServerActivity : BaseActivity() {
|
|||||||
val mldsa65Verify = et_mldsa65_verify?.text?.toString()
|
val mldsa65Verify = et_mldsa65_verify?.text?.toString()
|
||||||
val echConfigList = et_ech_config_list?.text?.toString()
|
val echConfigList = et_ech_config_list?.text?.toString()
|
||||||
val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0
|
val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0
|
||||||
|
val pinnedCA256 = et_pinned_ca256?.text?.toString()
|
||||||
|
|
||||||
val allowInsecure =
|
val allowInsecure =
|
||||||
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
|
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
|
||||||
@@ -585,6 +592,7 @@ class ServerActivity : BaseActivity() {
|
|||||||
config.mldsa65Verify = mldsa65Verify
|
config.mldsa65Verify = mldsa65Verify
|
||||||
config.echConfigList = echConfigList
|
config.echConfigList = echConfigList
|
||||||
config.echForceQuery = echForceQuerys[echForceQueryIndex]
|
config.echForceQuery = echForceQuerys[echForceQueryIndex]
|
||||||
|
config.pinnedCA256 = pinnedCA256
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun transportTypes(network: String?): Array<out String> {
|
private fun transportTypes(network: String?): Array<out String> {
|
||||||
@@ -632,7 +640,7 @@ class ServerActivity : BaseActivity() {
|
|||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
application.toast(R.string.toast_action_not_allowed)
|
toast(R.string.toast_action_not_allowed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import com.blacksquircle.ui.language.json.JsonLanguage
|
|||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
|
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.fmt.CustomFmt
|
import com.v2ray.ang.fmt.CustomFmt
|
||||||
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ class ServerCustomConfigActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
config.server = profileItem?.server
|
config.server = profileItem?.server
|
||||||
config.serverPort = profileItem?.serverPort
|
config.serverPort = profileItem?.serverPort
|
||||||
|
config.description = AngConfigManager.generateDescription(config)
|
||||||
|
|
||||||
MmkvManager.encodeServerConfig(editGuid, config)
|
MmkvManager.encodeServerConfig(editGuid, config)
|
||||||
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
|
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import android.widget.ArrayAdapter
|
|||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityServerGroupBinding
|
import com.v2ray.ang.databinding.ActivityServerGroupBinding
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
@@ -66,6 +67,11 @@ class ServerGroupActivity : BaseActivity() {
|
|||||||
private fun clearServer(): Boolean {
|
private fun clearServer(): Boolean {
|
||||||
binding.etRemarks.text = null
|
binding.etRemarks.text = null
|
||||||
binding.etPolicyGroupFilter.text = null
|
binding.etPolicyGroupFilter.text = null
|
||||||
|
|
||||||
|
if (subscriptionId.isNotNullEmpty()) {
|
||||||
|
val pos = subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
|
||||||
|
binding.spPolicyGroupSubId.setSelection(pos)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +96,9 @@ class ServerGroupActivity : BaseActivity() {
|
|||||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||||
config.subscriptionId = subscriptionId.orEmpty()
|
config.subscriptionId = subscriptionId.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
|
||||||
|
|
||||||
MmkvManager.encodeServerConfig(editGuid, config)
|
MmkvManager.encodeServerConfig(editGuid, config)
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
finish()
|
finish()
|
||||||
@@ -119,13 +128,13 @@ class ServerGroupActivity : BaseActivity() {
|
|||||||
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
|
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
|
||||||
subIds.clear()
|
subIds.clear()
|
||||||
subIds.add("") // index 0 => All
|
subIds.add("") // index 0 => All
|
||||||
subs.forEach { (id, item) ->
|
subs.forEach { sub ->
|
||||||
val name = when {
|
val name = when {
|
||||||
item.remarks.isNotBlank() -> item.remarks
|
sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
|
||||||
else -> id
|
else -> sub.guid
|
||||||
}
|
}
|
||||||
displayList.add(name)
|
displayList.add(name)
|
||||||
subIds.add(id)
|
subIds.add(sub.guid)
|
||||||
}
|
}
|
||||||
val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList)
|
val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList)
|
||||||
subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import com.v2ray.ang.AppConfig.VPN
|
|||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.extension.toLongEx
|
import com.v2ray.ang.extension.toLongEx
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.MmkvPreferenceDataStore
|
|
||||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||||
|
import com.v2ray.ang.helper.MmkvPreferenceDataStore
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -52,6 +52,10 @@ class SettingsActivity : BaseActivity() {
|
|||||||
private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
|
private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
|
||||||
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
|
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
|
||||||
|
|
||||||
|
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
|
||||||
|
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
|
||||||
|
private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
|
||||||
|
|
||||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||||
// Use MMKV as the storage backend for all Preferences
|
// Use MMKV as the storage backend for all Preferences
|
||||||
// This prevents inconsistencies between SharedPreferences and MMKV
|
// This prevents inconsistencies between SharedPreferences and MMKV
|
||||||
@@ -93,12 +97,21 @@ class SettingsActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
mode?.setOnPreferenceChangeListener { _, newValue ->
|
mode?.setOnPreferenceChangeListener { pref, newValue ->
|
||||||
updateMode(newValue.toString())
|
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
|
true
|
||||||
}
|
}
|
||||||
mode?.dialogLayoutResource = R.layout.preference_with_help_link
|
mode?.dialogLayoutResource = R.layout.preference_with_help_link
|
||||||
|
|
||||||
|
useHevTun?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
updateHevTunSettings(newValue as Boolean)
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initPreferenceSummaries() {
|
private fun initPreferenceSummaries() {
|
||||||
@@ -141,6 +154,8 @@ class SettingsActivity : BaseActivity() {
|
|||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
|
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
|
||||||
|
|
||||||
// Initialize mode-dependent UI states
|
// Initialize mode-dependent UI states
|
||||||
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
|
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
|
||||||
|
|
||||||
@@ -152,11 +167,10 @@ class SettingsActivity : BaseActivity() {
|
|||||||
|
|
||||||
// Initialize auto-update interval state
|
// Initialize auto-update interval state
|
||||||
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
|
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateMode(mode: String?) {
|
private fun updateMode(value: String?) {
|
||||||
val vpn = mode == VPN
|
val vpn = value == VPN
|
||||||
localDns?.isEnabled = vpn
|
localDns?.isEnabled = vpn
|
||||||
fakeDns?.isEnabled = vpn
|
fakeDns?.isEnabled = vpn
|
||||||
appendHttpProxy?.isEnabled = vpn
|
appendHttpProxy?.isEnabled = vpn
|
||||||
@@ -165,6 +179,8 @@ class SettingsActivity : BaseActivity() {
|
|||||||
vpnBypassLan?.isEnabled = vpn
|
vpnBypassLan?.isEnabled = vpn
|
||||||
vpnInterfaceAddress?.isEnabled = vpn
|
vpnInterfaceAddress?.isEnabled = vpn
|
||||||
vpnMtu?.isEnabled = vpn
|
vpnMtu?.isEnabled = vpn
|
||||||
|
useHevTun?.isEnabled = vpn
|
||||||
|
updateHevTunSettings(false)
|
||||||
if (vpn) {
|
if (vpn) {
|
||||||
updateLocalDns(
|
updateLocalDns(
|
||||||
MmkvManager.decodeSettingsBool(
|
MmkvManager.decodeSettingsBool(
|
||||||
@@ -172,6 +188,12 @@ class SettingsActivity : BaseActivity() {
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
updateHevTunSettings(
|
||||||
|
MmkvManager.decodeSettingsBool(
|
||||||
|
AppConfig.PREF_USE_HEV_TUNNEL,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +257,11 @@ class SettingsActivity : BaseActivity() {
|
|||||||
fragmentLength?.isEnabled = enabled
|
fragmentLength?.isEnabled = enabled
|
||||||
fragmentInterval?.isEnabled = enabled
|
fragmentInterval?.isEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateHevTunSettings(enabled: Boolean) {
|
||||||
|
hevTunLogLevel?.isEnabled = enabled
|
||||||
|
hevTunRwTimeout?.isEnabled = enabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onModeHelpClicked(view: View) {
|
fun onModeHelpClicked(view: View) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.v2ray.ang.extension.toast
|
|||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -118,7 +119,7 @@ class SubEditActivity : BaseActivity() {
|
|||||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
MmkvManager.removeSubscription(editSubId)
|
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ class SubEditActivity : BaseActivity() {
|
|||||||
.show()
|
.show()
|
||||||
} else {
|
} else {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
MmkvManager.removeSubscription(editSubId)
|
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -145,10 +146,6 @@ class SubEditActivity : BaseActivity() {
|
|||||||
del_config = menu.findItem(R.id.del_config)
|
del_config = menu.findItem(R.id.del_config)
|
||||||
save_config = menu.findItem(R.id.save_config)
|
save_config = menu.findItem(R.id.save_config)
|
||||||
|
|
||||||
if (editSubId.isEmpty()) {
|
|
||||||
del_config?.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onCreateOptionsMenu(menu)
|
return super.onCreateOptionsMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,26 @@ package com.v2ray.ang.ui
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||||
import com.v2ray.ang.databinding.ActivitySubSettingBinding
|
import com.v2ray.ang.databinding.ActivitySubSettingBinding
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||||
|
import com.v2ray.ang.util.QRCodeDecoder
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -22,17 +30,21 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
class SubSettingActivity : BaseActivity() {
|
class SubSettingActivity : BaseActivity() {
|
||||||
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
|
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
|
||||||
|
private val ownerActivity: SubSettingActivity
|
||||||
|
get() = this
|
||||||
private val viewModel: SubscriptionsViewModel by viewModels()
|
private val viewModel: SubscriptionsViewModel by viewModels()
|
||||||
private lateinit var adapter: SubSettingRecyclerAdapter
|
private lateinit var adapter: SubSettingRecyclerAdapter
|
||||||
private var mItemTouchHelper: ItemTouchHelper? = null
|
private var mItemTouchHelper: ItemTouchHelper? = null
|
||||||
|
private val share_method: Array<out String> by lazy {
|
||||||
|
resources.getStringArray(R.array.share_sub_method)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
//setContentView(binding.root)
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
||||||
|
|
||||||
adapter = SubSettingRecyclerAdapter(this, viewModel)
|
adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
|
||||||
|
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||||
@@ -63,16 +75,23 @@ class SubSettingActivity : BaseActivity() {
|
|||||||
showLoading()
|
showLoading()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val count = AngConfigManager.updateConfigViaSubAll()
|
val result = AngConfigManager.updateConfigViaSubAll()
|
||||||
delay(500L)
|
delay(500L)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
if (count > 0) {
|
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||||
toastSuccess(R.string.toast_success)
|
toast(R.string.title_update_subscription_no_subscription)
|
||||||
refreshData()
|
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||||
|
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||||
} else {
|
} else {
|
||||||
toastError(R.string.toast_failure)
|
toast(
|
||||||
|
getString(
|
||||||
|
R.string.title_update_subscription_result,
|
||||||
|
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
hideLoading()
|
hideLoading()
|
||||||
|
refreshData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,4 +107,62 @@ class SubSettingActivity : BaseActivity() {
|
|||||||
viewModel.reload()
|
viewModel.reload()
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||||
|
override fun onEdit(guid: String, position: Int) {
|
||||||
|
startActivity(
|
||||||
|
Intent(ownerActivity, SubEditActivity::class.java)
|
||||||
|
.putExtra("subId", guid)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemove(guid: String, position: Int) {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||||
|
AlertDialog.Builder(ownerActivity)
|
||||||
|
.setMessage(R.string.del_config_comfirm)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
viewModel.remove(guid)
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
viewModel.remove(guid)
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShare(url: String) {
|
||||||
|
AlertDialog.Builder(ownerActivity)
|
||||||
|
.setItems(share_method.asList().toTypedArray()) { _, i ->
|
||||||
|
try {
|
||||||
|
when (i) {
|
||||||
|
0 -> {
|
||||||
|
val ivBinding =
|
||||||
|
ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
|
||||||
|
ivBinding.ivQcode.setImageBitmap(
|
||||||
|
QRCodeDecoder.createQRCode(
|
||||||
|
url
|
||||||
|
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
Utils.setClipboard(ownerActivity, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ownerActivity.toast("else")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "Share subscription failed", e)
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRefreshData() {
|
||||||
|
refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,31 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
|
||||||
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
|
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
|
||||||
import com.v2ray.ang.extension.toast
|
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
|
||||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||||
import com.v2ray.ang.util.QRCodeDecoder
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||||
|
|
||||||
class SubSettingRecyclerAdapter(
|
class SubSettingRecyclerAdapter(
|
||||||
val activity: SubSettingActivity,
|
private val viewModel: SubscriptionsViewModel,
|
||||||
private val viewModel: SubscriptionsViewModel
|
private val adapterListener: BaseAdapterListener?
|
||||||
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
|
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
|
||||||
|
|
||||||
private var mActivity: SubSettingActivity = activity
|
|
||||||
|
|
||||||
private val share_method: Array<out String> by lazy {
|
|
||||||
mActivity.resources.getStringArray(R.array.share_sub_method)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() = viewModel.getAll().size
|
override fun getItemCount() = viewModel.getAll().size
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||||
val subscriptions = viewModel.getAll()
|
val subscriptions = viewModel.getAll()
|
||||||
val subId = subscriptions[position].first
|
val subId = subscriptions[position].guid
|
||||||
val subItem = subscriptions[position].second
|
val subItem = subscriptions[position].subscription
|
||||||
holder.itemSubSettingBinding.tvName.text = subItem.remarks
|
holder.itemSubSettingBinding.tvName.text = subItem.remarks
|
||||||
holder.itemSubSettingBinding.tvUrl.text = subItem.url
|
holder.itemSubSettingBinding.tvUrl.text = subItem.url
|
||||||
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
|
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
|
||||||
@@ -45,14 +33,11 @@ class SubSettingRecyclerAdapter(
|
|||||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||||
|
|
||||||
holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
|
holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
|
||||||
mActivity.startActivity(
|
adapterListener?.onEdit(subId, position)
|
||||||
Intent(mActivity, SubEditActivity::class.java)
|
|
||||||
.putExtra("subId", subId)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
|
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
|
||||||
removeSubscription(subId, position)
|
adapterListener?.onRemove(subId, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||||
@@ -72,58 +57,11 @@ class SubSettingRecyclerAdapter(
|
|||||||
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
|
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
|
||||||
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
|
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
|
||||||
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
|
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
|
||||||
AlertDialog.Builder(mActivity)
|
adapterListener?.onShare(subItem.url)
|
||||||
.setItems(share_method.asList().toTypedArray()) { _, i ->
|
|
||||||
try {
|
|
||||||
when (i) {
|
|
||||||
0 -> {
|
|
||||||
val ivBinding =
|
|
||||||
ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
|
|
||||||
ivBinding.ivQcode.setImageBitmap(
|
|
||||||
QRCodeDecoder.createQRCode(
|
|
||||||
subItem.url
|
|
||||||
|
|
||||||
)
|
|
||||||
)
|
|
||||||
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
1 -> {
|
|
||||||
Utils.setClipboard(mActivity, subItem.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> mActivity.toast("else")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Share subscription failed", e)
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeSubscription(subId: String, position: Int) {
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
|
||||||
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
removeSubscriptionSub(subId, position)
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
|
||||||
//do noting
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
} else {
|
|
||||||
removeSubscriptionSub(subId, position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeSubscriptionSub(subId: String, position: Int) {
|
|
||||||
viewModel.remove(subId)
|
|
||||||
notifyItemRemoved(position)
|
|
||||||
notifyItemRangeChanged(position, viewModel.getAll().size)
|
|
||||||
mActivity.refreshData()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
|
||||||
return MainViewHolder(
|
return MainViewHolder(
|
||||||
ItemRecyclerSubSettingBinding.inflate(
|
ItemRecyclerSubSettingBinding.inflate(
|
||||||
@@ -154,7 +92,7 @@ class SubSettingRecyclerAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemMoveCompleted() {
|
override fun onItemMoveCompleted() {
|
||||||
mActivity.refreshData()
|
adapterListener?.onRefreshData()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemDismiss(position: Int) {
|
override fun onItemDismiss(position: Int) {
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ class TaskerActivity : BaseActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
//setContentView(binding.root)
|
||||||
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "")
|
||||||
|
|
||||||
//add def value
|
//add def value
|
||||||
lstData.add("Default")
|
lstData.add("Default")
|
||||||
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
||||||
|
|
||||||
MmkvManager.decodeServerList().forEach { key ->
|
MmkvManager.decodeAllServerList().forEach { key ->
|
||||||
MmkvManager.decodeServerConfig(key)?.let { config ->
|
MmkvManager.decodeServerConfig(key)?.let { config ->
|
||||||
lstData.add(config.remarks)
|
lstData.add(config.remarks)
|
||||||
lstGuid.add(key)
|
lstGuid.add(key)
|
||||||
|
|||||||
@@ -1,95 +1,52 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View.GONE
|
import androidx.activity.viewModels
|
||||||
import android.view.View.VISIBLE
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||||
import com.v2ray.ang.databinding.ActivityUserAssetBinding
|
import com.v2ray.ang.databinding.ActivityUserAssetBinding
|
||||||
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
|
|
||||||
import com.v2ray.ang.dto.AssetUrlItem
|
import com.v2ray.ang.dto.AssetUrlItem
|
||||||
import com.v2ray.ang.extension.concatUrl
|
|
||||||
import com.v2ray.ang.extension.toTrafficString
|
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.util.HttpUtil
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.text.DateFormat
|
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
class UserAssetActivity : BaseActivity() {
|
class UserAssetActivity : HelperBaseActivity() {
|
||||||
private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) }
|
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)) }
|
val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||||
val builtInGeoFiles = arrayOf("geosite.dat", "geoip.dat")
|
|
||||||
|
|
||||||
private val requestStoragePermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { isGranted: Boolean ->
|
|
||||||
if (isGranted) {
|
|
||||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
|
||||||
intent.type = "*/*"
|
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
|
|
||||||
try {
|
|
||||||
chooseFile.launch(
|
|
||||||
Intent.createChooser(
|
|
||||||
intent,
|
|
||||||
getString(R.string.title_file_chooser)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (ex: android.content.ActivityNotFoundException) {
|
|
||||||
toast(R.string.toast_require_file_manager)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast(R.string.toast_permission_denied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val requestCameraPermissionLauncher = registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { isGranted: Boolean ->
|
|
||||||
if (isGranted) {
|
|
||||||
scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java))
|
|
||||||
} else {
|
|
||||||
toast(R.string.toast_permission_denied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
|
||||||
|
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||||
binding.recyclerView.adapter = UserAssetAdapter()
|
adapter = UserAssetAdapter(viewModel, extDir, ActivityAdapterListener())
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
|
binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
|
||||||
binding.layoutGeoFilesSources.setOnClickListener {
|
binding.layoutGeoFilesSources.setOnClickListener {
|
||||||
@@ -133,17 +90,11 @@ class UserAssetActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showFileChooser() {
|
private fun showFileChooser() {
|
||||||
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
launchFileChooser { uri ->
|
||||||
Manifest.permission.READ_MEDIA_IMAGES
|
if (uri == null) {
|
||||||
} else {
|
return@launchFileChooser
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
}
|
||||||
}
|
|
||||||
requestStoragePermissionLauncher.launch(permission)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
||||||
val uri = result.data?.data
|
|
||||||
if (result.resultCode == RESULT_OK && uri != null) {
|
|
||||||
val assetId = Utils.getUuid()
|
val assetId = Utils.getUuid()
|
||||||
runCatching {
|
runCatching {
|
||||||
val assetItem = AssetUrlItem(
|
val assetItem = AssetUrlItem(
|
||||||
@@ -152,7 +103,7 @@ class UserAssetActivity : BaseActivity() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val assetList = MmkvManager.decodeAssetUrls()
|
val assetList = MmkvManager.decodeAssetUrls()
|
||||||
if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) {
|
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
|
||||||
toast(R.string.msg_remark_is_duplicate)
|
toast(R.string.msg_remark_is_duplicate)
|
||||||
} else {
|
} else {
|
||||||
MmkvManager.encodeAsset(assetId, assetItem)
|
MmkvManager.encodeAsset(assetId, assetItem)
|
||||||
@@ -190,15 +141,14 @@ class UserAssetActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun importAssetFromQRcode(): Boolean {
|
private fun importAssetFromQRcode(): Boolean {
|
||||||
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
launchQRCodeScanner { scanResult ->
|
||||||
|
if (scanResult != null) {
|
||||||
|
importAsset(scanResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private val scanQRCodeForAssetURL = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
if (it.resultCode == RESULT_OK) {
|
|
||||||
importAsset(it.data?.getStringExtra("SCAN_RESULT"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun importAsset(url: String?): Boolean {
|
private fun importAsset(url: String?): Boolean {
|
||||||
try {
|
try {
|
||||||
@@ -219,81 +169,25 @@ class UserAssetActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadGeoFiles() {
|
private fun downloadGeoFiles() {
|
||||||
|
refreshData()
|
||||||
showLoading()
|
showLoading()
|
||||||
toast(R.string.msg_downloading_content)
|
toast(R.string.msg_downloading_content)
|
||||||
|
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
var assets = MmkvManager.decodeAssetUrls()
|
|
||||||
assets = addBuiltInGeoItems(assets)
|
|
||||||
|
|
||||||
var resultCount = 0
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
assets.forEach {
|
val result = viewModel.downloadGeoFiles(extDir, httpPort)
|
||||||
try {
|
|
||||||
var result = downloadGeo(it.second, 15000, httpPort)
|
|
||||||
if (!result) {
|
|
||||||
result = downloadGeo(it.second, 15000, 0)
|
|
||||||
}
|
|
||||||
if (result)
|
|
||||||
resultCount++
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to download geo file: ${it.second.remarks}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (resultCount > 0) {
|
if (result.successCount > 0) {
|
||||||
toast(getString(R.string.title_update_config_count, resultCount))
|
toast(getString(R.string.title_update_config_count, result.successCount))
|
||||||
refreshData()
|
|
||||||
} else {
|
} else {
|
||||||
toast(getString(R.string.toast_failure))
|
toast(getString(R.string.toast_failure))
|
||||||
}
|
}
|
||||||
|
refreshData()
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadGeo(item: AssetUrlItem, timeout: Int, httpPort: Int): Boolean {
|
|
||||||
val targetTemp = File(extDir, item.remarks + "_temp")
|
|
||||||
val target = File(extDir, item.remarks)
|
|
||||||
Log.i(AppConfig.TAG, "Downloading geo file: ${item.remarks} from ${item.url}")
|
|
||||||
|
|
||||||
val conn = HttpUtil.createProxyConnection(item.url, httpPort, timeout, timeout, needStream = true) ?: return false
|
|
||||||
try {
|
|
||||||
val inputStream = conn.inputStream
|
|
||||||
val responseCode = conn.responseCode
|
|
||||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
|
||||||
FileOutputStream(targetTemp).use { output ->
|
|
||||||
inputStream.copyTo(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
targetTemp.renameTo(target)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
conn.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addBuiltInGeoItems(assets: List<Pair<String, AssetUrlItem>>): List<Pair<String, AssetUrlItem>> {
|
|
||||||
val list = mutableListOf<Pair<String, AssetUrlItem>>()
|
|
||||||
builtInGeoFiles
|
|
||||||
.filter { geoFile -> assets.none { it.second.remarks == geoFile } }
|
|
||||||
.forEach {
|
|
||||||
list.add(
|
|
||||||
Utils.getUuid() to AssetUrlItem(
|
|
||||||
it,
|
|
||||||
String.format(AppConfig.GITHUB_DOWNLOAD_URL, getGeoFilesSources()).concatUrl(it),
|
|
||||||
locked = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return list + assets
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initAssets() {
|
fun initAssets() {
|
||||||
lifecycleScope.launch(Dispatchers.Default) {
|
lifecycleScope.launch(Dispatchers.Default) {
|
||||||
SettingsManager.initAssets(this@UserAssetActivity, assets)
|
SettingsManager.initAssets(this@UserAssetActivity, assets)
|
||||||
@@ -305,72 +199,41 @@ class UserAssetActivity : BaseActivity() {
|
|||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
fun refreshData() {
|
fun refreshData() {
|
||||||
binding.recyclerView.adapter?.notifyDataSetChanged()
|
viewModel.reload(getGeoFilesSources())
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class UserAssetAdapter : RecyclerView.Adapter<UserAssetViewHolder>() {
|
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
|
override fun onEdit(guid: String, position: Int) {
|
||||||
return UserAssetViewHolder(
|
startActivity(
|
||||||
ItemRecyclerUserAssetBinding.inflate(
|
Intent(ownerActivity, UserAssetUrlActivity::class.java)
|
||||||
LayoutInflater.from(parent.context),
|
.putExtra("assetId", guid)
|
||||||
parent,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
override fun onRemove(guid: String, position: Int) {
|
||||||
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
|
val asset = viewModel.getAsset(position)?.takeIf { it.guid == guid }
|
||||||
var assets = MmkvManager.decodeAssetUrls()
|
?: viewModel.getAssets().find { it.guid == guid }
|
||||||
assets = addBuiltInGeoItems(assets)
|
?: return
|
||||||
val item = assets.getOrNull(position) ?: return
|
val file = extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }
|
||||||
// file with name == item.second.remarks
|
|
||||||
val file = extDir.listFiles()?.find { it.name == item.second.remarks }
|
|
||||||
|
|
||||||
holder.itemUserAssetBinding.assetName.text = item.second.remarks
|
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
if (file != null) {
|
file?.delete()
|
||||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
MmkvManager.removeAssetUrl(guid)
|
||||||
holder.itemUserAssetBinding.assetProperties.text =
|
initAssets()
|
||||||
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
|
}
|
||||||
} else {
|
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||||
holder.itemUserAssetBinding.assetProperties.text = getString(R.string.msg_file_not_found)
|
// do nothing
|
||||||
}
|
}
|
||||||
|
.show()
|
||||||
if (item.second.locked == true) {
|
|
||||||
holder.itemUserAssetBinding.layoutEdit.visibility = GONE
|
|
||||||
//holder.itemUserAssetBinding.layoutRemove.visibility = GONE
|
|
||||||
} else {
|
|
||||||
holder.itemUserAssetBinding.layoutEdit.visibility = item.second.url.let { if (it == "file") GONE else VISIBLE }
|
|
||||||
//holder.itemUserAssetBinding.layoutRemove.visibility = VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
|
|
||||||
val intent = Intent(this@UserAssetActivity, UserAssetUrlActivity::class.java)
|
|
||||||
intent.putExtra("assetId", item.first)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
|
|
||||||
AlertDialog.Builder(this@UserAssetActivity).setMessage(R.string.del_config_comfirm)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
file?.delete()
|
|
||||||
MmkvManager.removeAssetUrl(item.first)
|
|
||||||
initAssets()
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
|
||||||
//do noting
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun onShare(url: String) {
|
||||||
var assets = MmkvManager.decodeAssetUrls()
|
}
|
||||||
assets = addBuiltInGeoItems(assets)
|
|
||||||
return assets.size
|
override fun onRefreshData() {
|
||||||
|
refreshData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
|
|
||||||
RecyclerView.ViewHolder(itemUserAssetBinding.root)
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||||
|
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
|
||||||
|
import com.v2ray.ang.extension.toTrafficString
|
||||||
|
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
||||||
|
import java.io.File
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class UserAssetAdapter(
|
||||||
|
private val viewModel: UserAssetViewModel,
|
||||||
|
private val extDir: File,
|
||||||
|
private val adapterListener: BaseAdapterListener?
|
||||||
|
) : RecyclerView.Adapter<UserAssetAdapter.UserAssetViewHolder>() {
|
||||||
|
|
||||||
|
override fun getItemCount() = viewModel.itemCount
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
|
||||||
|
return UserAssetViewHolder(
|
||||||
|
ItemRecyclerUserAssetBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
|
||||||
|
val item = viewModel.getAsset(position) ?: return
|
||||||
|
val file = extDir.listFiles()?.find { it.name == item.assetUrl.remarks }
|
||||||
|
|
||||||
|
holder.itemUserAssetBinding.assetName.text = item.assetUrl.remarks
|
||||||
|
|
||||||
|
if (file != null) {
|
||||||
|
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||||
|
holder.itemUserAssetBinding.assetProperties.text =
|
||||||
|
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
|
||||||
|
} else {
|
||||||
|
holder.itemUserAssetBinding.assetProperties.text =
|
||||||
|
holder.itemUserAssetBinding.root.context.getString(R.string.msg_file_not_found)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.assetUrl.locked == true) {
|
||||||
|
holder.itemUserAssetBinding.layoutEdit.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
holder.itemUserAssetBinding.layoutEdit.visibility = if (item.assetUrl.url == "file") {
|
||||||
|
View.GONE
|
||||||
|
} else {
|
||||||
|
View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
|
||||||
|
adapterListener?.onEdit(item.guid, position)
|
||||||
|
}
|
||||||
|
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
|
||||||
|
adapterListener?.onRemove(item.guid, position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
|
||||||
|
RecyclerView.ViewHolder(itemUserAssetBinding.root)
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ class UserAssetUrlActivity : BaseActivity() {
|
|||||||
|
|
||||||
// check remarks unique
|
// check remarks unique
|
||||||
val assetList = MmkvManager.decodeAssetUrls()
|
val assetList = MmkvManager.decodeAssetUrls()
|
||||||
if (assetList.any { it.second.remarks == assetItem.remarks && it.first != assetId }) {
|
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
|
||||||
toast(R.string.msg_remark_is_duplicate)
|
toast(R.string.msg_remark_is_duplicate)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.TestServiceMessage
|
||||||
import com.v2ray.ang.service.V2RayTestService
|
import com.v2ray.ang.service.V2RayTestService
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
@@ -37,15 +38,13 @@ object MessageUtil {
|
|||||||
* Sends a message to the test service.
|
* Sends a message to the test service.
|
||||||
*
|
*
|
||||||
* @param ctx The context.
|
* @param ctx The context.
|
||||||
* @param what The message identifier.
|
* @param message The test service message containing key, subscriptionId, and serverGuids.
|
||||||
* @param content The message content.
|
|
||||||
*/
|
*/
|
||||||
fun sendMsg2TestService(ctx: Context, what: Int, content: Serializable) {
|
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
|
||||||
try {
|
try {
|
||||||
val intent = Intent()
|
val intent = Intent()
|
||||||
intent.component = ComponentName(ctx, V2RayTestService::class.java)
|
intent.component = ComponentName(ctx, V2RayTestService::class.java)
|
||||||
intent.putExtra("key", what)
|
intent.putExtra("content", message)
|
||||||
intent.putExtra("content", content)
|
|
||||||
ctx.startService(intent)
|
ctx.startService(intent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to send message to test service", e)
|
Log.e(AppConfig.TAG, "Failed to send message to test service", e)
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ object QRCodeDecoder {
|
|||||||
val qrReader = QRCodeReader()
|
val qrReader = QRCodeReader()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), mapOf(DecodeHintType.TRY_HARDER to true)).text
|
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source)), HINTS).text
|
||||||
} catch (e: NotFoundException) {
|
} catch (e: NotFoundException) {
|
||||||
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())), mapOf(DecodeHintType.TRY_HARDER to true)).text
|
qrReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())), HINTS).text
|
||||||
}
|
}
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
@@ -97,27 +97,9 @@ object QRCodeDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val allFormats: List<BarcodeFormat> = arrayListOf(
|
// Keep decoding hints focused on QR codes and enable TRY_HARDER + UTF-8 charset for better success rate.
|
||||||
BarcodeFormat.AZTEC,
|
HINTS[DecodeHintType.TRY_HARDER] = true
|
||||||
BarcodeFormat.CODABAR,
|
HINTS[DecodeHintType.POSSIBLE_FORMATS] = listOf(BarcodeFormat.QR_CODE)
|
||||||
BarcodeFormat.CODE_39,
|
HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8.name()
|
||||||
BarcodeFormat.CODE_93,
|
|
||||||
BarcodeFormat.CODE_128,
|
|
||||||
BarcodeFormat.DATA_MATRIX,
|
|
||||||
BarcodeFormat.EAN_8,
|
|
||||||
BarcodeFormat.EAN_13,
|
|
||||||
BarcodeFormat.ITF,
|
|
||||||
BarcodeFormat.MAXICODE,
|
|
||||||
BarcodeFormat.PDF_417,
|
|
||||||
BarcodeFormat.QR_CODE,
|
|
||||||
BarcodeFormat.RSS_14,
|
|
||||||
BarcodeFormat.RSS_EXPANDED,
|
|
||||||
BarcodeFormat.UPC_A,
|
|
||||||
BarcodeFormat.UPC_E,
|
|
||||||
BarcodeFormat.UPC_EAN_EXTENSION
|
|
||||||
)
|
|
||||||
HINTS[DecodeHintType.TRY_HARDER] = BarcodeFormat.QR_CODE
|
|
||||||
HINTS[DecodeHintType.POSSIBLE_FORMATS] = allFormats
|
|
||||||
HINTS[DecodeHintType.CHARACTER_SET] = Charsets.UTF_8
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,13 +328,48 @@ object Utils {
|
|||||||
*/
|
*/
|
||||||
fun urlEncode(url: String): String {
|
fun urlEncode(url: String): String {
|
||||||
return try {
|
return try {
|
||||||
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
|
URLEncoder.encode(url, Charsets.UTF_8.toString())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to encode URL", e)
|
Log.e(AppConfig.TAG, "Failed to encode URL", e)
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a "encodeURIComponent" string.
|
||||||
|
*
|
||||||
|
* @param url The "encodeURIComponent" string.
|
||||||
|
* @return The decoded string, or the original string if decoding fails.
|
||||||
|
*/
|
||||||
|
fun decodeURIComponent(url: String): String {
|
||||||
|
return try {
|
||||||
|
// Decode strictly according to RFC 3986 / encodeURIComponent semantics.
|
||||||
|
// '+' is a literal plus and MUST NOT be interpreted as space.
|
||||||
|
// Inputs using '+' for spaces are non-conforming and rejected deliberately
|
||||||
|
// to avoid cross-language interoperability issues.
|
||||||
|
URLDecoder.decode(url.replace("+", "%2B"), Charsets.UTF_8.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "Failed to decode encodeURIComponent", e)
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a string to "encodeURIComponent" format.
|
||||||
|
*
|
||||||
|
* @param url The string to encode.
|
||||||
|
* @return The "encodeURIComponent" encoded string, or the original string if encoding fails.
|
||||||
|
*/
|
||||||
|
fun encodeURIComponent(url: String): String {
|
||||||
|
return try {
|
||||||
|
// Replace '+' with '%20' to conform to encodeURIComponent semantics.
|
||||||
|
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "Failed to encode encodeURIComponent", e)
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read text from an asset file.
|
* Read text from an asset file.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.v2ray.ang.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class LogcatViewModel : ViewModel() {
|
||||||
|
private val logsetsAll: MutableList<String> = mutableListOf()
|
||||||
|
private var filteredLogs: List<String> = emptyList()
|
||||||
|
private var currentFilter: String = ""
|
||||||
|
|
||||||
|
fun getAll(): List<String> = filteredLogs
|
||||||
|
|
||||||
|
fun loadLogcat() {
|
||||||
|
try {
|
||||||
|
val lst = LinkedHashSet<String>()
|
||||||
|
lst.add("logcat")
|
||||||
|
lst.add("-d")
|
||||||
|
lst.add("-v")
|
||||||
|
lst.add("time")
|
||||||
|
lst.add("-s")
|
||||||
|
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
|
||||||
|
val process = Runtime.getRuntime().exec(lst.toTypedArray())
|
||||||
|
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
|
||||||
|
|
||||||
|
logsetsAll.clear()
|
||||||
|
logsetsAll.addAll(allText)
|
||||||
|
applyFilter()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
android.util.Log.e(AppConfig.TAG, "Failed to get logcat", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLogcat() {
|
||||||
|
try {
|
||||||
|
val lst = LinkedHashSet<String>()
|
||||||
|
lst.add("logcat")
|
||||||
|
lst.add("-c")
|
||||||
|
val process = Runtime.getRuntime().exec(lst.toTypedArray())
|
||||||
|
process.waitFor()
|
||||||
|
|
||||||
|
logsetsAll.clear()
|
||||||
|
filteredLogs = emptyList()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
android.util.Log.e(AppConfig.TAG, "Failed to clear logcat", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filter(content: String?) {
|
||||||
|
currentFilter = content?.trim() ?: ""
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFilter() {
|
||||||
|
filteredLogs = if (currentFilter.isEmpty()) {
|
||||||
|
logsetsAll.toList()
|
||||||
|
} else {
|
||||||
|
logsetsAll.filter { it.contains(currentFilter) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@ import com.v2ray.ang.AngApplication
|
|||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.dto.GroupMapItem
|
import com.v2ray.ang.dto.GroupMapItem
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.ServersCache
|
import com.v2ray.ang.dto.ServersCache
|
||||||
|
import com.v2ray.ang.dto.SubscriptionCache
|
||||||
|
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||||
|
import com.v2ray.ang.dto.TestServiceMessage
|
||||||
import com.v2ray.ang.extension.serializable
|
import com.v2ray.ang.extension.serializable
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
@@ -35,10 +37,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
|
|
||||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private var serverList = MmkvManager.decodeServerList()
|
private var serverList = mutableListOf<String>() // MmkvManager.decodeServerList()
|
||||||
var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
|
var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
|
||||||
|
|
||||||
//var keywordFilter: String = MmkvManager.MmkvManager.decodeSettingsString(AppConfig.CACHE_KEYWORD_FILTER, "")?:""
|
|
||||||
var keywordFilter = ""
|
var keywordFilter = ""
|
||||||
val serversCache = mutableListOf<ServersCache>()
|
val serversCache = mutableListOf<ServersCache>()
|
||||||
val isRunning by lazy { MutableLiveData<Boolean>() }
|
val isRunning by lazy { MutableLiveData<Boolean>() }
|
||||||
@@ -69,10 +69,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reloads the server list.
|
* Reloads the server list based on current subscription filter.
|
||||||
*/
|
*/
|
||||||
fun reloadServerList() {
|
fun reloadServerList() {
|
||||||
serverList = MmkvManager.decodeServerList()
|
serverList = if (subscriptionId.isEmpty()) {
|
||||||
|
MmkvManager.decodeAllServerList()
|
||||||
|
} else {
|
||||||
|
MmkvManager.decodeServerList(subscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
updateCache()
|
updateCache()
|
||||||
updateListAction.value = -1
|
updateListAction.value = -1
|
||||||
}
|
}
|
||||||
@@ -90,38 +95,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Appends a custom configuration server.
|
|
||||||
// * @param server The server configuration to append.
|
|
||||||
// * @return True if the server was successfully appended, false otherwise.
|
|
||||||
// */
|
|
||||||
// fun appendCustomConfigServer(server: String): Boolean {
|
|
||||||
// if (server.contains("inbounds")
|
|
||||||
// && server.contains("outbounds")
|
|
||||||
// && server.contains("routing")
|
|
||||||
// ) {
|
|
||||||
// try {
|
|
||||||
// val config = CustomFmt.parse(server) ?: return false
|
|
||||||
// config.subscriptionId = subscriptionId
|
|
||||||
// val key = MmkvManager.encodeServerConfig("", config)
|
|
||||||
// MmkvManager.encodeServerRaw(key, server)
|
|
||||||
// serverList.add(0, key)
|
|
||||||
//// val profile = ProfileLiteItem(
|
|
||||||
//// configType = config.configType,
|
|
||||||
//// subscriptionId = config.subscriptionId,
|
|
||||||
//// remarks = config.remarks,
|
|
||||||
//// server = config.getProxyOutbound()?.getServerAddress(),
|
|
||||||
//// serverPort = config.getProxyOutbound()?.getServerPort(),
|
|
||||||
//// )
|
|
||||||
// serversCache.add(0, ServersCache(key, config))
|
|
||||||
// return true
|
|
||||||
// } catch (e: Exception) {
|
|
||||||
// e.printStackTrace()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return false
|
|
||||||
// }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Swaps the positions of two servers.
|
* Swaps the positions of two servers.
|
||||||
* @param fromPosition The initial position of the server.
|
* @param fromPosition The initial position of the server.
|
||||||
@@ -129,14 +102,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
*/
|
*/
|
||||||
fun swapServer(fromPosition: Int, toPosition: Int) {
|
fun swapServer(fromPosition: Int, toPosition: Int) {
|
||||||
if (subscriptionId.isEmpty()) {
|
if (subscriptionId.isEmpty()) {
|
||||||
Collections.swap(serverList, fromPosition, toPosition)
|
return
|
||||||
} else {
|
|
||||||
val fromPosition2 = serverList.indexOf(serversCache[fromPosition].guid)
|
|
||||||
val toPosition2 = serverList.indexOf(serversCache[toPosition].guid)
|
|
||||||
Collections.swap(serverList, fromPosition2, toPosition2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Collections.swap(serverList, fromPosition, toPosition)
|
||||||
Collections.swap(serversCache, fromPosition, toPosition)
|
Collections.swap(serversCache, fromPosition, toPosition)
|
||||||
MmkvManager.encodeServerList(serverList)
|
|
||||||
|
MmkvManager.encodeServerList(serverList, subscriptionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,26 +117,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
fun updateCache() {
|
fun updateCache() {
|
||||||
serversCache.clear()
|
serversCache.clear()
|
||||||
|
val kw = keywordFilter.trim().lowercase()
|
||||||
for (guid in serverList) {
|
for (guid in serverList) {
|
||||||
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
|
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
|
||||||
// var profile = MmkvManager.decodeProfileConfig(guid)
|
if (kw.isEmpty()) {
|
||||||
// if (profile == null) {
|
serversCache.add(ServersCache(guid, profile))
|
||||||
// val config = MmkvManager.decodeServerConfig(guid) ?: continue
|
|
||||||
// profile = ProfileLiteItem(
|
|
||||||
// configType = config.configType,
|
|
||||||
// subscriptionId = config.subscriptionId,
|
|
||||||
// remarks = config.remarks,
|
|
||||||
// server = config.getProxyOutbound()?.getServerAddress(),
|
|
||||||
// serverPort = config.getProxyOutbound()?.getServerPort(),
|
|
||||||
// )
|
|
||||||
// MmkvManager.encodeServerConfig(guid, config)
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (subscriptionId.isNotEmpty() && subscriptionId != profile.subscriptionId) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keywordFilter.isEmpty() || profile.remarks.lowercase().contains(keywordFilter.lowercase())) {
|
val remarks = profile.remarks.lowercase()
|
||||||
|
val description = profile.description.orEmpty().lowercase()
|
||||||
|
val server = profile.server.orEmpty().lowercase()
|
||||||
|
|
||||||
|
if (remarks.contains(kw) || description.contains(kw) || server.contains(kw)) {
|
||||||
serversCache.add(ServersCache(guid, profile))
|
serversCache.add(ServersCache(guid, profile))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,14 +137,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the configuration via subscription for all servers.
|
* Updates the configuration via subscription for all servers.
|
||||||
* @return The number of updated configurations.
|
* @return Detailed result of the subscription update operation.
|
||||||
*/
|
*/
|
||||||
fun updateConfigViaSubAll(): Int {
|
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
|
||||||
if (subscriptionId.isEmpty()) {
|
if (subscriptionId.isEmpty()) {
|
||||||
return AngConfigManager.updateConfigViaSubAll()
|
return AngConfigManager.updateConfigViaSubAll()
|
||||||
} else {
|
} else {
|
||||||
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return 0
|
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return SubscriptionUpdateResult()
|
||||||
return AngConfigManager.updateConfigViaSub(Pair(subscriptionId, subItem))
|
return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,17 +197,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* Tests the real ping for all servers.
|
* Tests the real ping for all servers.
|
||||||
*/
|
*/
|
||||||
fun testAllRealPing() {
|
fun testAllRealPing() {
|
||||||
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG_CANCEL, "")
|
MessageUtil.sendMsg2TestService(
|
||||||
|
getApplication(),
|
||||||
|
TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL)
|
||||||
|
)
|
||||||
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
|
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
|
||||||
updateListAction.value = -1
|
updateListAction.value = -1
|
||||||
|
|
||||||
val serversCopy = serversCache.toList()
|
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
val guids = ArrayList<String>(serversCopy.map { it.guid })
|
if (serversCache.isEmpty()) {
|
||||||
if (guids.isEmpty()) {
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, guids)
|
MessageUtil.sendMsg2TestService(
|
||||||
|
getApplication(),
|
||||||
|
TestServiceMessage(
|
||||||
|
key = AppConfig.MSG_MEASURE_CONFIG,
|
||||||
|
subscriptionId = subscriptionId,
|
||||||
|
serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,20 +246,27 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fun getSubscriptions(context: Context): List<GroupMapItem> {
|
fun getSubscriptions(context: Context): List<GroupMapItem> {
|
||||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||||
if (subscriptionId.isNotEmpty()
|
if (subscriptionId.isNotEmpty()
|
||||||
&& !subscriptions.map { it.first }.contains(subscriptionId)
|
&& !subscriptions.map { it.guid }.contains(subscriptionId)
|
||||||
) {
|
) {
|
||||||
subscriptionIdChanged("")
|
subscriptionIdChanged("")
|
||||||
}
|
}
|
||||||
|
|
||||||
val groups = mutableListOf<GroupMapItem>()
|
val groups = mutableListOf<GroupMapItem>()
|
||||||
groups.add(
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)) {
|
||||||
GroupMapItem(
|
groups.add(
|
||||||
id = "",
|
GroupMapItem(
|
||||||
remarks = context.getString(R.string.filter_config_all)
|
id = "",
|
||||||
|
remarks = context.getString(R.string.filter_config_all)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
subscriptions.forEach { sub ->
|
||||||
|
groups.add(
|
||||||
|
GroupMapItem(
|
||||||
|
id = sub.guid,
|
||||||
|
remarks = sub.subscription.remarks
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
subscriptions.forEach { (id, item) ->
|
|
||||||
groups.add(GroupMapItem(id = id, remarks = item.remarks))
|
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
@@ -309,20 +289,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* @return The number of removed servers.
|
* @return The number of removed servers.
|
||||||
*/
|
*/
|
||||||
fun removeDuplicateServer(): Int {
|
fun removeDuplicateServer(): Int {
|
||||||
val serversCacheCopy = mutableListOf<Pair<String, ProfileItem>>()
|
val serversCacheCopy = serversCache.toList().toMutableList()
|
||||||
for (it in serversCache) {
|
|
||||||
val config = MmkvManager.decodeServerConfig(it.guid) ?: continue
|
|
||||||
serversCacheCopy.add(Pair(it.guid, config))
|
|
||||||
}
|
|
||||||
|
|
||||||
val deleteServer = mutableListOf<String>()
|
val deleteServer = mutableListOf<String>()
|
||||||
serversCacheCopy.forEachIndexed { index, it ->
|
serversCacheCopy.forEachIndexed { index, sc ->
|
||||||
val outbound = it.second
|
val profile = sc.profile
|
||||||
serversCacheCopy.forEachIndexed { index2, it2 ->
|
serversCacheCopy.forEachIndexed { index2, sc2 ->
|
||||||
if (index2 > index) {
|
if (index2 > index) {
|
||||||
val outbound2 = it2.second
|
val profile2 = sc2.profile
|
||||||
if (outbound.equals(outbound2) && !deleteServer.contains(it2.first)) {
|
if (profile == profile2 && !deleteServer.contains(sc2.guid)) {
|
||||||
deleteServer.add(it2.first)
|
deleteServer.add(sc2.guid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,24 +348,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* Sorts servers by their test results.
|
* Sorts servers by their test results.
|
||||||
*/
|
*/
|
||||||
fun sortByTestResults() {
|
fun sortByTestResults() {
|
||||||
|
if (subscriptionId.isEmpty()) {
|
||||||
|
MmkvManager.decodeSubsList().forEach { guid ->
|
||||||
|
sortByTestResultsForSub(guid)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sortByTestResultsForSub(subscriptionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts servers by their test results for a specific subscription.
|
||||||
|
* @param subId The subscription ID to sort servers for.
|
||||||
|
*/
|
||||||
|
private fun sortByTestResultsForSub(subId: String) {
|
||||||
data class ServerDelay(var guid: String, var testDelayMillis: Long)
|
data class ServerDelay(var guid: String, var testDelayMillis: Long)
|
||||||
|
|
||||||
val serverDelays = mutableListOf<ServerDelay>()
|
val serverDelays = mutableListOf<ServerDelay>()
|
||||||
val serverList = MmkvManager.decodeServerList()
|
val serverListToSort = MmkvManager.decodeServerList(subId)
|
||||||
serverList.forEach { key ->
|
|
||||||
|
serverListToSort.forEach { key ->
|
||||||
val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
|
val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
|
||||||
serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
|
serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
|
||||||
}
|
}
|
||||||
serverDelays.sortBy { it.testDelayMillis }
|
serverDelays.sortBy { it.testDelayMillis }
|
||||||
|
|
||||||
serverDelays.forEach {
|
val sortedServerList = serverDelays.map { it.guid }.toMutableList()
|
||||||
serverList.remove(it.guid)
|
|
||||||
serverList.add(it.guid)
|
|
||||||
}
|
|
||||||
|
|
||||||
MmkvManager.encodeServerList(serverList)
|
// Save the sorted list for this subscription
|
||||||
|
MmkvManager.encodeServerList(sortedServerList, subId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes assets.
|
* Initializes assets.
|
||||||
* @param assets The asset manager.
|
* @param assets The asset manager.
|
||||||
@@ -410,10 +399,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
keywordFilter = keyword
|
keywordFilter = keyword
|
||||||
MmkvManager.encodeSettings(AppConfig.CACHE_KEYWORD_FILTER, keywordFilter)
|
|
||||||
reloadServerList()
|
reloadServerList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun findSubscriptionIdBySelect(): String? {
|
||||||
|
// Get the selected server GUID
|
||||||
|
val selectedGuid = MmkvManager.getSelectServer()
|
||||||
|
if (selectedGuid.isNullOrEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = MmkvManager.decodeServerConfig(selectedGuid)
|
||||||
|
return config?.subscriptionId
|
||||||
|
}
|
||||||
|
|
||||||
fun onTestsFinished() {
|
fun onTestsFinished() {
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST)) {
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST)) {
|
||||||
@@ -480,4 +479,4 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
package com.v2ray.ang.viewmodel
|
package com.v2ray.ang.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.v2ray.ang.dto.SubscriptionCache
|
||||||
import com.v2ray.ang.dto.SubscriptionItem
|
import com.v2ray.ang.dto.SubscriptionItem
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
|
||||||
class SubscriptionsViewModel : ViewModel() {
|
class SubscriptionsViewModel : ViewModel() {
|
||||||
private val subscriptions: MutableList<Pair<String, SubscriptionItem>> =
|
private val subscriptions: MutableList<SubscriptionCache> =
|
||||||
MmkvManager.decodeSubscriptions().toMutableList()
|
MmkvManager.decodeSubscriptions().toMutableList()
|
||||||
|
|
||||||
fun getAll(): List<Pair<String, SubscriptionItem>> = subscriptions.toList()
|
fun getAll(): List<SubscriptionCache> = subscriptions.toList()
|
||||||
|
|
||||||
fun reload() {
|
fun reload() {
|
||||||
subscriptions.clear()
|
subscriptions.clear()
|
||||||
@@ -18,18 +19,18 @@ class SubscriptionsViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun remove(subId: String): Boolean {
|
fun remove(subId: String): Boolean {
|
||||||
val changed = subscriptions.removeAll { it.first == subId }
|
val changed = subscriptions.removeAll { it.guid == subId }
|
||||||
if (changed) {
|
if (changed) {
|
||||||
MmkvManager.removeSubscription(subId)
|
SettingsManager.removeSubscriptionWithDefault(subId)
|
||||||
SettingsChangeManager.makeSetupGroupTab()
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
}
|
}
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(subId: String, item: SubscriptionItem) {
|
fun update(subId: String, item: SubscriptionItem) {
|
||||||
val idx = subscriptions.indexOfFirst { it.first == subId }
|
val idx = subscriptions.indexOfFirst { it.guid == subId }
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
subscriptions[idx] = Pair(subId, item)
|
subscriptions[idx] = SubscriptionCache(subId, item)
|
||||||
MmkvManager.encodeSubscription(subId, item)
|
MmkvManager.encodeSubscription(subId, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.v2ray.ang.viewmodel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.AssetUrlCache
|
||||||
|
import com.v2ray.ang.dto.AssetUrlItem
|
||||||
|
import com.v2ray.ang.extension.concatUrl
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.util.HttpUtil
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
|
||||||
|
class UserAssetViewModel : ViewModel() {
|
||||||
|
private val assets = mutableListOf<AssetUrlCache>()
|
||||||
|
private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||||
|
|
||||||
|
val itemCount: Int
|
||||||
|
get() = assets.size
|
||||||
|
|
||||||
|
fun getAssets(): List<AssetUrlCache> = assets.toList()
|
||||||
|
|
||||||
|
fun getAsset(position: Int): AssetUrlCache? = assets.getOrNull(position)
|
||||||
|
|
||||||
|
fun reload(geoFilesSource: String) {
|
||||||
|
val decoded = MmkvManager.decodeAssetUrls()
|
||||||
|
assets.clear()
|
||||||
|
assets.addAll(buildAssetList(decoded, geoFilesSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildAssetList(
|
||||||
|
decodedAssets: List<AssetUrlCache>?,
|
||||||
|
geoFilesSource: String
|
||||||
|
): List<AssetUrlCache> {
|
||||||
|
val savedAssets = decodedAssets ?: emptyList()
|
||||||
|
val builtInItems = builtInGeoFiles
|
||||||
|
.filter { geoFile -> savedAssets.none { it.assetUrl.remarks == geoFile } }
|
||||||
|
.map {
|
||||||
|
AssetUrlCache(
|
||||||
|
Utils.getUuid(),
|
||||||
|
AssetUrlItem(
|
||||||
|
it,
|
||||||
|
String.format(AppConfig.GITHUB_DOWNLOAD_URL, geoFilesSource).concatUrl(it),
|
||||||
|
locked = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Force update URL for geoip-only-cn-private.dat
|
||||||
|
return (builtInItems + savedAssets).map { cache ->
|
||||||
|
if (cache.assetUrl.remarks == AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT) {
|
||||||
|
cache.copy(
|
||||||
|
assetUrl = cache.assetUrl.copy(
|
||||||
|
url = AppConfig.GEOIP_ONLY_CN_PRIVATE_URL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadGeoFiles(extDir: File, httpPort: Int): GeoDownloadResult {
|
||||||
|
val snapshot = getAssets()
|
||||||
|
var successCount = 0
|
||||||
|
val failures = mutableListOf<String>()
|
||||||
|
|
||||||
|
snapshot.forEach { cache ->
|
||||||
|
val item = cache.assetUrl
|
||||||
|
val portsToTry = if (httpPort == 0) listOf(0) else listOf(httpPort, 0)
|
||||||
|
if (portsToTry.any { tryDownload(item, extDir, it) }) {
|
||||||
|
successCount++
|
||||||
|
} else {
|
||||||
|
failures.add(item.remarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GeoDownloadResult(successCount, failures.size, failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryDownload(item: AssetUrlItem, extDir: File, httpPort: Int): Boolean {
|
||||||
|
val targetTemp = File(extDir, item.remarks + "_temp")
|
||||||
|
val target = File(extDir, item.remarks)
|
||||||
|
val conn = HttpUtil.createProxyConnection(item.url, httpPort, 15000, 15000, needStream = true) ?: return false
|
||||||
|
try {
|
||||||
|
val responseCode = conn.responseCode
|
||||||
|
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||||
|
conn.inputStream.use { inputStream ->
|
||||||
|
FileOutputStream(targetTemp).use { output ->
|
||||||
|
inputStream.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targetTemp.renameTo(target)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GeoDownloadResult(
|
||||||
|
val successCount: Int,
|
||||||
|
val failureCount: Int,
|
||||||
|
val failedAssets: List<String>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_source_code_24dp" />
|
app:srcCompat="@drawable/ic_source_code_24dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/license_24px" />
|
app:srcCompat="@drawable/license_24px" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_feedback_24dp" />
|
app:srcCompat="@drawable/ic_feedback_24dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -102,6 +105,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_telegram_24dp" />
|
app:srcCompat="@drawable/ic_telegram_24dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -126,6 +130,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_privacy_24dp" />
|
app:srcCompat="@drawable/ic_privacy_24dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_backup_24dp" />
|
app:srcCompat="@drawable/ic_backup_24dp" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_share_24dp" />
|
app:srcCompat="@drawable/ic_share_24dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -85,6 +87,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_restore_24dp" />
|
app:srcCompat="@drawable/ic_restore_24dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -117,6 +120,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_settings_24dp" />
|
app:srcCompat="@drawable/ic_settings_24dp" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/switch_bypass_apps_mode"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:padding="@dimen/padding_spacing_dp8">
|
android:padding="@dimen/padding_spacing_dp8">
|
||||||
@@ -75,6 +76,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_about_24dp" />
|
app:srcCompat="@drawable/ic_about_24dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_source_code_24dp" />
|
app:srcCompat="@drawable/ic_source_code_24dp" />
|
||||||
|
|
||||||
<androidx.appcompat.widget.SwitchCompat
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_check_update_24dp" />
|
app:srcCompat="@drawable/ic_check_update_24dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
tools:context=".ui.LogcatActivity">
|
tools:context=".ui.LogcatActivity">
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/refreshLayout"
|
android:id="@+id/refresh_layout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="@dimen/view_height_dp64"
|
android:layout_height="@dimen/view_height_dp64"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/connection_test_pending"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:nextFocusLeft="@+id/view_pager"
|
android:nextFocusLeft="@+id/view_pager"
|
||||||
android:nextFocusRight="@+id/fab"
|
android:nextFocusRight="@+id/fab"
|
||||||
@@ -95,6 +96,18 @@
|
|||||||
android:layout_gravity="bottom|end"
|
android:layout_gravity="bottom|end"
|
||||||
android:layout_marginEnd="@dimen/padding_spacing_dp16">
|
android:layout_marginEnd="@dimen/padding_spacing_dp16">
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/fab_locate"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_marginBottom="@dimen/view_height_dp120"
|
||||||
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/title_server"
|
||||||
|
android:focusable="true"
|
||||||
|
android:src="@android:drawable/ic_menu_mylocation"
|
||||||
|
app:layout_anchorGravity="bottom|right|end" />
|
||||||
|
|
||||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
android:id="@+id/fab"
|
android:id="@+id/fab"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/recycler_view"
|
android:id="@+id/refresh_layout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent">
|
||||||
android:scrollbars="vertical" />
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recycler_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:scrollbars="vertical" />
|
||||||
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
android:id="@+id/iv_qcode"
|
android:id="@+id/iv_qcode"
|
||||||
android:layout_width="336dp"
|
android:layout_width="336dp"
|
||||||
android:layout_height="336dp"
|
android:layout_height="336dp"
|
||||||
|
android:contentDescription="@string/title_qr_code"
|
||||||
android:scaleType="fitXY"
|
android:scaleType="fitXY"
|
||||||
app:srcCompat="@drawable/ic_fab_check" />
|
app:srcCompat="@drawable/ic_fab_check" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
android:layout_height="@dimen/padding_spacing_dp16"
|
android:layout_height="@dimen/padding_spacing_dp16"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:layout_marginStart="@dimen/padding_spacing_dp16"
|
android:layout_marginStart="@dimen/padding_spacing_dp16"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_lock_24dp" />
|
app:srcCompat="@drawable/ic_lock_24dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/menu_item_edit_config"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:nextFocusLeft="@+id/info_container"
|
android:nextFocusLeft="@+id/info_container"
|
||||||
@@ -87,6 +89,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_edit_24dp" />
|
app:srcCompat="@drawable/ic_edit_24dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/title_configuration_share"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:nextFocusLeft="@+id/info_container"
|
android:nextFocusLeft="@+id/info_container"
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_share_24dp" />
|
app:srcCompat="@drawable/ic_share_24dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/menu_item_edit_config"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
@@ -85,6 +88,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_edit_24dp" />
|
app:srcCompat="@drawable/ic_edit_24dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -95,6 +99,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/menu_item_del_config"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
@@ -103,6 +108,7 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_delete_24dp" />
|
app:srcCompat="@drawable/ic_delete_24dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -58,12 +58,14 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/menu_item_edit_config"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:padding="@dimen/padding_spacing_dp8">
|
android:padding="@dimen/padding_spacing_dp8">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_edit_24dp" />
|
app:srcCompat="@drawable/ic_edit_24dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -73,12 +75,14 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:contentDescription="@string/menu_item_del_config"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:padding="@dimen/padding_spacing_dp8">
|
android:padding="@dimen/padding_spacing_dp8">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="@dimen/image_size_dp24"
|
android:layout_width="@dimen/image_size_dp24"
|
||||||
android:layout_height="@dimen/image_size_dp24"
|
android:layout_height="@dimen/image_size_dp24"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
app:srcCompat="@drawable/ic_delete_24dp" />
|
app:srcCompat="@drawable/ic_delete_24dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -158,6 +158,27 @@
|
|||||||
android:entries="@array/ech_force_query_value" />
|
android:entries="@array/ech_force_query_value" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/lay_pinned_ca256"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="@dimen/padding_spacing_dp16"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/server_lab_pinned_ca256" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/et_pinned_ca256"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:nextFocusDown="@+id/sp_ech_force_query" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/lay_public_key"
|
android:id="@+id/lay_public_key"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -80,10 +80,10 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/server_lab_stream_pinsha256" />
|
android:text="@string/server_lab_pinned_ca256" />
|
||||||
|
|
||||||
<EditText
|
<EditText
|
||||||
android:id="@+id/et_pinsha256"
|
android:id="@+id/et_pinned_ca256"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="text" />
|
android:inputType="text" />
|
||||||
|
|||||||
@@ -8,4 +8,5 @@
|
|||||||
android:text="@string/title_mode_help"
|
android:text="@string/title_mode_help"
|
||||||
android:textAlignment="textStart"
|
android:textAlignment="textStart"
|
||||||
android:textStyle="italic"
|
android:textStyle="italic"
|
||||||
|
android:textColor="?attr/colorPrimary"
|
||||||
tools:ignore="UsingOnClickInXml" />
|
tools:ignore="UsingOnClickInXml" />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
android:id="@+id/image_switch"
|
android:id="@+id/image_switch"
|
||||||
android:layout_width="45dp"
|
android:layout_width="45dp"
|
||||||
android:layout_height="45dp"
|
android:layout_height="45dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
android:padding="@dimen/padding_spacing_dp16"
|
android:padding="@dimen/padding_spacing_dp16"
|
||||||
app:srcCompat="@drawable/ic_stat_name" />
|
app:srcCompat="@drawable/ic_stat_name" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user