Compare commits

..

33 Commits

Author SHA1 Message Date
2dust fa4432584e up 2.0.5 2026-01-21 17:44:03 +08:00
2dust bb1c3d915f Refactor user asset management with ViewModel and adapter 2026-01-21 17:43:34 +08:00
2dust d8983a4761 Bug fix
https://github.com/2dust/v2rayNG/issues/5186
2026-01-20 21:07:51 +08:00
2dust 9815041c77 Remove unused imports and commented debug logs 2026-01-20 10:52:52 +08:00
2dust 41b0983582 Refactor logcat handling to use LogcatViewModel 2026-01-20 10:46:46 +08:00
2dust bb1f677d45 Refactor RoutingSetting adapter to use listener interface 2026-01-20 10:17:57 +08:00
2dust de1c8bcfcc Refactor VPN mode checks and TUN config logic
Centralizes VPN mode detection in SettingsManager with a new isVpnMode() method and updates all usages to reference it. Refactors TUN configuration logic in V2rayConfigManager for clarity and correctness, ensuring proper config and routing rules are applied based on VPN and HEV TUN settings.
2026-01-19 19:43:09 +08:00
2dust ca6e963575 Refactor remarks assignment using ifEmpty in formatters 2026-01-19 19:18:26 +08:00
2dust bfb6813dc7 Fix HevTun settings to preferences UI 2026-01-19 19:18:15 +08:00
2dust cc2b4eec65 up 2.0.4 2026-01-18 15:23:02 +08:00
2dust dd11569969 Add version field to Hysteria2 config 2026-01-18 15:22:33 +08:00
2dust 11c3dd97c3 Update AndroidLibXrayLite 2026-01-18 15:00:07 +08:00
2dust 92f15150cd up 2.0.3 2026-01-18 14:58:40 +08:00
2dust f8e809da8b Refactor adapter callbacks with BaseAdapterListener 2026-01-18 14:58:15 +08:00
DHR60 12b9349e62 Fix sniffing (#5173)
* Fix sniffing

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

* Update RoutingType.kt

* Add custom routing file in assets

* Update localization
2026-01-15 10:34:11 +08:00
2dust ee69364f2b Remove hysteria submodule and related build steps 2026-01-15 10:32:10 +08:00
2dust 6be0bd7b21 Remove plugin framework and refactor Hysteria2 integration 2026-01-15 10:19:25 +08:00
67 changed files with 983 additions and 1729 deletions
+2 -32
View File
@@ -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: |
@@ -77,36 +77,6 @@ jobs:
fileName: 'libv2ray.aar' fileName: 'libv2ray.aar'
out-file-path: V2rayNG/app/libs/ out-file-path: V2rayNG/app/libs/
- name: Restore cached libhysteria2
id: cache-libhysteria2-restore
uses: actions/cache/restore@v4
with:
path: ${{ github.workspace }}/hysteria/libs
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
- name: Setup Golang
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/setup-go@v5.4.0
with:
go-version-file: 'AndroidLibXrayLite/go.mod'
cache: false
- name: Build libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
run: |
bash libhysteria2.sh
- name: Save libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ${{ github.workspace }}/hysteria/libs
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
- name: Copy libhysteria2
run: |
cp -r ${{ github.workspace }}/hysteria/libs ${{ github.workspace }}/V2rayNG/app
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4.7.0 uses: actions/setup-java@v4.7.0
with: with:
-3
View File
@@ -1,6 +1,3 @@
[submodule "hysteria"]
path = hysteria
url = https://github.com/apernet/hysteria
[submodule "AndroidLibXrayLite"] [submodule "AndroidLibXrayLite"]
path = AndroidLibXrayLite path = AndroidLibXrayLite
url = https://github.com/2dust/AndroidLibXrayLite url = https://github.com/2dust/AndroidLibXrayLite
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang" applicationId = "com.v2ray.ang"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 700 versionCode = 705
versionName = "2.0.0" versionName = "2.0.5"
multiDexEnabled = true multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -0,0 +1,43 @@
[
{
"remarks": "Bypass bittorrent",
"outboundTag": "direct",
"protocol": [
"bittorrent"
]
},
{
"remarks": "Block udp443",
"outboundTag": "block",
"port": "443",
"network": "udp"
},
{
"remarks": "Direct LAN IP",
"outboundTag": "direct",
"ip": [
"geoip:private"
]
},
{
"remarks": "Direct LAN domains",
"outboundTag": "direct",
"domain": [
"geosite:private"
]
},
{
"remarks": "Bypass Russia domains",
"outboundTag": "direct",
"domain": [
"geosite:category-ru"
]
},
{
"remarks": "Bypass Russia IP",
"outboundTag": "direct",
"ip": [
"geoip:ru"
]
}
]
@@ -42,6 +42,13 @@
"name": "xray0", "name": "xray0",
"MTU": 1500, "MTU": 1500,
"userLevel": 8 "userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
} }
} }
], ],
@@ -9,7 +9,9 @@ object AppConfig {
/** Directory names used in the app's file system. */ /** Directory names used in the app's file system. */
const val DIR_ASSETS = "assets" const val DIR_ASSETS = "assets"
const val DIR_BACKUPS = "backups"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
/** Legacy configuration keys. */ /** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config" const val ANG_CONFIG = "ang_config"
@@ -169,6 +171,7 @@ object AppConfig {
const val TROJAN = "trojan://" const val TROJAN = "trojan://"
const val WIREGUARD = "wireguard://" const val WIREGUARD = "wireguard://"
const val TUIC = "tuic://" const val TUIC = "tuic://"
const val HYSTERIA = "hysteria://"
const val HYSTERIA2 = "hysteria2://" const val HYSTERIA2 = "hysteria2://"
const val HY2 = "hy2://" const val HY2 = "hy2://"
@@ -4,6 +4,5 @@ data class ConfigResult(
var status: Boolean, var status: Boolean,
var guid: String? = null, var guid: String? = null,
var content: String = "", var content: String = "",
var socksPort: Int? = null,
) )
@@ -14,6 +14,7 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
// TUIC(8, AppConfig.TUIC), // TUIC(8, AppConfig.TUIC),
HYSTERIA2(9, AppConfig.HYSTERIA2), HYSTERIA2(9, AppConfig.HYSTERIA2),
HYSTERIA(900, AppConfig.HYSTERIA),
HTTP(10, AppConfig.HTTP), HTTP(10, AppConfig.HTTP),
POLICYGROUP (101, AppConfig.CUSTOM); POLICYGROUP (101, AppConfig.CUSTOM);
@@ -1,46 +0,0 @@
package com.v2ray.ang.dto
data class Hysteria2Bean(
val server: String?,
val auth: String?,
val lazy: Boolean? = true,
val obfs: ObfsBean? = null,
val socks5: Socks5Bean? = null,
val http: Socks5Bean? = null,
val tls: TlsBean? = null,
val transport: TransportBean? = null,
val bandwidth: BandwidthBean? = null,
) {
data class ObfsBean(
val type: String?,
val salamander: SalamanderBean?
) {
data class SalamanderBean(
val password: String?,
)
}
data class Socks5Bean(
val listen: String?,
)
data class TlsBean(
val sni: String?,
val insecure: Boolean?,
val pinSHA256: String?,
)
data class TransportBean(
val type: String?,
val udp: TransportUdpBean?
) {
data class TransportUdpBean(
val hopInterval: String?,
)
}
data class BandwidthBean(
val down: String?,
val up: String?,
)
}
@@ -10,7 +10,8 @@ enum class NetworkType(val type: String) {
H2("h2"), H2("h2"),
//QUIC("quic"), //QUIC("quic"),
GRPC("grpc"); GRPC("grpc"),
HYSTERIA("hysteria");
companion object { companion object {
fun fromString(type: String?) = entries.find { it.type == type } ?: TCP fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
@@ -4,7 +4,8 @@ enum class RoutingType(val fileName: String) {
WHITE("custom_routing_white"), WHITE("custom_routing_white"),
BLACK("custom_routing_black"), BLACK("custom_routing_black"),
GLOBAL("custom_routing_global"), GLOBAL("custom_routing_global"),
WHITE_IRAN("custom_routing_white_iran"); WHITE_IRAN("custom_routing_white_iran"),
WHITE_RUSSIA("custom_routing_white_russia");
companion object { companion object {
fun fromIndex(index: Int): RoutingType { fun fromIndex(index: Int): RoutingType {
@@ -13,6 +14,7 @@ enum class RoutingType(val fileName: String) {
1 -> BLACK 1 -> BLACK
2 -> GLOBAL 2 -> GLOBAL
3 -> WHITE_IRAN 3 -> WHITE_IRAN
4 -> WHITE_RUSSIA
else -> WHITE else -> WHITE
} }
} }
@@ -2,7 +2,6 @@ package com.v2ray.ang.dto
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.Utils
data class V2rayConfig( data class V2rayConfig(
var remarks: String? = null, var remarks: String? = null,
@@ -35,7 +34,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
) { ) {
@@ -76,7 +75,7 @@ data class V2rayConfig(
/*DNS*/ /*DNS*/
val network: String? = null, val network: String? = null,
var address: Any? = null, var address: Any? = null,
val port: Int? = null, var port: Int? = null,
/*Freedom*/ /*Freedom*/
var domainStrategy: String? = null, var domainStrategy: String? = null,
val redirect: String? = null, val redirect: String? = null,
@@ -89,6 +88,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 +160,8 @@ data class V2rayConfig(
var quicSettings: QuicSettingBean? = null, var quicSettings: QuicSettingBean? = null,
var realitySettings: TlsSettingsBean? = null, var realitySettings: TlsSettingsBean? = null,
var grpcSettings: GrpcSettingsBean? = null, var grpcSettings: GrpcSettingsBean? = null,
var hy2steriaSettings: Hy2steriaSettingsBean? = null, var hysteriaSettings: HysteriaSettingsBean? = null,
var udpmasks: List<UdpMasksBean>? = null,
val dsSettings: Any? = null, val dsSettings: Any? = null,
var sockopt: SockoptBean? = null var sockopt: SockoptBean? = null
) { ) {
@@ -247,7 +248,8 @@ data class V2rayConfig(
var dialerProxy: String? = null, var dialerProxy: String? = null,
var domainStrategy: String? = null, var domainStrategy: String? = null,
var happyEyeballs: HappyEyeballsBean? = null, var happyEyeballs: HappyEyeballsBean? = null,
) )
data class HappyEyeballsBean( data class HappyEyeballsBean(
var prioritizeIPv6: Boolean? = null, var prioritizeIPv6: Boolean? = null,
var maxConcurrentTry: Int? = 4, var maxConcurrentTry: Int? = 4,
@@ -293,18 +295,27 @@ data class V2rayConfig(
var health_check_timeout: Int? = null var health_check_timeout: Int? = null
) )
data class Hy2steriaSettingsBean( data class HysteriaSettingsBean(
var password: String? = null, var version: Int,
var use_udp_extension: Boolean? = true, var auth: String? = null,
var congestion: Hy2CongestionBean? = null var up: String? = null,
var down: String? = null,
var udphop: HysteriaUdpHopBean? = null
) { ) {
data class Hy2CongestionBean( data class HysteriaUdpHopBean(
var type: String? = "bbr", var port: String? = null,
var up_mbps: Int? = null, var interval: Int? = null
var down_mbps: Int? = null,
) )
} }
data class UdpMasksBean(
var type: String,
var settings: UdpMasksSettingsBean? = null
) {
data class UdpMasksSettingsBean(
var password: String? = null
)
}
} }
data class MuxBean( data class MuxBean(
@@ -323,11 +334,14 @@ data class V2rayConfig(
|| protocol.equals(EConfigType.SOCKS.name, true) || protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true) || protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true) || protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) { ) {
return settings?.servers?.first()?.address return settings?.servers?.first()?.address
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringBeforeLast(":") return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.address as String?
} }
return null return null
} }
@@ -341,132 +355,14 @@ data class V2rayConfig(
|| protocol.equals(EConfigType.SOCKS.name, true) || protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true) || protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true) || protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) { ) {
return settings?.servers?.first()?.port return settings?.servers?.first()?.port
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) { } else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt() return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
} } else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
return null || protocol.equals(EConfigType.HYSTERIA.name, true)
}
fun getServerAddressAndPort(): String {
val address = getServerAddress().orEmpty()
val port = getServerPort()
return Utils.getIpv6Address(address) + ":" + port
}
fun getPassword(): String? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) { ) {
return settings?.vnext?.first()?.users?.first()?.id return settings?.port
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) {
return settings?.servers?.first()?.password
} else if (protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
) {
return settings?.servers?.first()?.users?.first()?.pass
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.secretKey
}
return null
}
fun getSecurityEncryption(): String? {
return when {
protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.first()?.users?.first()?.security
protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.first()?.users?.first()?.encryption
protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.first()?.method
else -> null
}
}
fun getTransportSettingDetails(): List<String?>? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.SHADOWSOCKS.name, true)
) {
val transport = streamSettings?.network ?: return null
return when (transport) {
NetworkType.TCP.type -> {
val tcpSetting = streamSettings?.tcpSettings ?: return null
listOf(
tcpSetting.header.type,
tcpSetting.header.request?.headers?.Host?.joinToString(",").orEmpty(),
tcpSetting.header.request?.path?.joinToString(",").orEmpty()
)
}
NetworkType.KCP.type -> {
val kcpSetting = streamSettings?.kcpSettings ?: return null
listOf(
kcpSetting.header.type,
"",
kcpSetting.seed.orEmpty()
)
}
NetworkType.WS.type -> {
val wsSetting = streamSettings?.wsSettings ?: return null
listOf(
"",
wsSetting.headers.Host,
wsSetting.path
)
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = streamSettings?.httpupgradeSettings ?: return null
listOf(
"",
httpupgradeSetting.host,
httpupgradeSetting.path
)
}
NetworkType.XHTTP.type -> {
val xhttpSettings = streamSettings?.xhttpSettings ?: return null
listOf(
"",
xhttpSettings.host,
xhttpSettings.path
)
}
NetworkType.H2.type -> {
val h2Setting = streamSettings?.httpSettings ?: return null
listOf(
"",
h2Setting.host.joinToString(","),
h2Setting.path
)
}
// "quic" -> {
// val quicSetting = streamSettings?.quicSettings ?: return null
// listOf(
// quicSetting.header.type,
// quicSetting.security,
// quicSetting.key
// )
// }
NetworkType.GRPC.type -> {
val grpcSetting = streamSettings?.grpcSettings ?: return null
listOf(
if (grpcSetting.multiMode == true) "multi" else "gun",
grpcSetting.authority.orEmpty(),
grpcSetting.serviceName
)
}
else -> null
}
} }
return null return null
} }
@@ -168,7 +168,7 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
* *
* @return True if the CharSequence is not null and not empty, false otherwise. * @return True if the CharSequence is not null and not empty, false otherwise.
*/ */
fun CharSequence?.isNotNullEmpty(): Boolean = this != null && this.isNotEmpty() fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
fun String.concatUrl(vararg paths: String): String { fun String.concatUrl(vararg paths: String): String {
val builder = StringBuilder(this.trimEnd('/')) val builder = StringBuilder(this.trimEnd('/'))
@@ -158,6 +158,8 @@ open class FmtBase {
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() } config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() }
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() } config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() }
} }
else -> {}
} }
return dicQuery return dicQuery
@@ -1,11 +1,12 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.Hysteria2Bean 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.UdpMasksBean.UdpMasksSettingsBean
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.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -25,11 +26,12 @@ 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.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
config.security = AppConfig.TLS config.security = AppConfig.TLS
config.network = NetworkType.HYSTERIA.type
if (!uri.rawQuery.isNullOrEmpty()) { if (!uri.rawQuery.isNullOrEmpty()) {
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
@@ -80,64 +82,6 @@ object Hysteria2Fmt : FmtBase() {
return toUri(config, config.password, dicQuery) return toUri(config, config.password, dicQuery)
} }
/**
* Converts a ProfileItem object to a Hysteria2Bean object.
*
* @param config the ProfileItem object to convert
* @param socksPort the port number for the socks5 proxy
* @return the converted Hysteria2Bean object, or null if conversion fails
*/
fun toNativeConfig(config: ProfileItem, socksPort: Int): Hysteria2Bean? {
val obfs = if (config.obfsPassword.isNullOrEmpty()) null else
Hysteria2Bean.ObfsBean(
type = "salamander",
salamander = Hysteria2Bean.ObfsBean.SalamanderBean(
password = config.obfsPassword
)
)
val transport = if (config.portHopping.isNullOrEmpty()) null else
Hysteria2Bean.TransportBean(
type = "udp",
udp = Hysteria2Bean.TransportBean.TransportUdpBean(
hopInterval = (config.portHoppingInterval?.takeIf { it.isNotEmpty() } ?: "30") + "s"
)
)
val bandwidth = if (config.bandwidthDown.isNullOrEmpty() || config.bandwidthUp.isNullOrEmpty()) null else
Hysteria2Bean.BandwidthBean(
down = config.bandwidthDown,
up = config.bandwidthUp,
)
val server =
if (config.portHopping.isNullOrEmpty())
config.getServerAddressAndPort()
else
Utils.getIpv6Address(config.server) + ":" + config.portHopping
val bean = Hysteria2Bean(
server = server,
auth = config.password,
obfs = obfs,
transport = transport,
bandwidth = bandwidth,
socks5 = Hysteria2Bean.Socks5Bean(
listen = "$LOOPBACK:${socksPort}",
),
http = Hysteria2Bean.Socks5Bean(
listen = "$LOOPBACK:${socksPort}",
),
tls = Hysteria2Bean.TlsBean(
sni = config.sni ?: config.server,
insecure = config.insecure,
pinSHA256 = if (config.pinSHA256.isNullOrEmpty()) null else config.pinSHA256
)
)
return bean
}
/** /**
* Converts a ProfileItem object to an OutboundBean object. * Converts a ProfileItem object to an OutboundBean object.
* *
@@ -145,7 +89,34 @@ object Hysteria2Fmt : FmtBase() {
* @return the converted OutboundBean object, or null if conversion fails * @return the converted OutboundBean object, or null if conversion fails
*/ */
fun toOutbound(profileItem: ProfileItem): OutboundBean? { fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) ?: return null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
if (profileItem.obfsPassword.isNotNullEmpty()) {
outboundBean.streamSettings?.udpmasks = mutableListOf(
StreamSettingsBean.UdpMasksBean(
type = "salamander",
settings = UdpMasksSettingsBean(
password = profileItem.obfsPassword
)
)
)
}
return outboundBean return outboundBean
} }
} }
@@ -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.urlDecode(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()
@@ -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.urlDecode(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()
@@ -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.urlDecode(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
@@ -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.urlDecode(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
@@ -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.urlDecode(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
@@ -25,7 +25,7 @@ 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.urlDecode(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()
@@ -110,13 +110,6 @@ object AngConfigManager {
if (guid == null) return -1 if (guid == null) return -1
val result = V2rayConfigManager.getV2rayConfig(context, guid) val result = V2rayConfigManager.getV2rayConfig(context, guid)
if (result.status) { if (result.status) {
val config = MmkvManager.decodeServerConfig(guid)
if (config?.configType == EConfigType.HYSTERIA2) {
val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort)
Utils.setClipboard(context, JsonUtil.toJsonPretty(hy2Config) + "\n" + result.content)
return 0
}
Utils.setClipboard(context, result.content) Utils.setClipboard(context, result.content)
} else { } else {
return -1 return -1
@@ -149,6 +142,7 @@ object AngConfigManager {
EConfigType.WIREGUARD -> WireguardFmt.toUri(config) EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config) EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
EConfigType.POLICYGROUP -> "" EConfigType.POLICYGROUP -> ""
else -> {}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e) Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
@@ -1,141 +0,0 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.service.ProcessService
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import java.io.File
object PluginServiceManager {
private const val HYSTERIA2 = "libhysteria2.so"
private val procService: ProcessService by lazy {
ProcessService()
}
/**
* Run the plugin based on the provided configuration.
*
* @param context The context to use.
* @param config The profile configuration.
* @param socksPort The port information.
*/
fun runPlugin(context: Context, config: ProfileItem?, socksPort: Int?) {
Log.i(AppConfig.TAG, "Starting plugin execution")
if (config == null) {
Log.w(AppConfig.TAG, "Cannot run plugin: config is null")
return
}
try {
if (config.configType == EConfigType.HYSTERIA2) {
if (socksPort == null) {
Log.w(AppConfig.TAG, "Cannot run plugin: socksPort is null")
return
}
Log.i(AppConfig.TAG, "Running Hysteria2 plugin")
val configFile = genConfigHy2(context, config, socksPort) ?: return
val cmd = genCmdHy2(context, configFile)
procService.runProcess(context, cmd)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error running plugin", e)
}
}
/**
* Stop the running plugin.
*/
fun stopPlugin() {
stopHy2()
}
/**
* Perform a real ping using Hysteria2.
*
* @param context The context to use.
* @param config The profile configuration.
* @return The ping delay in milliseconds, or -1 if it fails.
*/
fun realPingHy2(context: Context, config: ProfileItem?): Long {
Log.i(AppConfig.TAG, "realPingHy2")
val retFailure = -1L
if (config?.configType?.equals(EConfigType.HYSTERIA2) == true) {
val socksPort = Utils.findFreePort(listOf(0))
val configFile = genConfigHy2(context, config, socksPort) ?: return retFailure
val cmd = genCmdHy2(context, configFile)
val proc = ProcessService()
proc.runProcess(context, cmd)
Thread.sleep(1000L)
val delay = SpeedtestManager.testConnection(context, socksPort)
proc.stopProcess()
return delay.first
}
return retFailure
}
/**
* Generate the configuration file for Hysteria2.
*
* @param context The context to use.
* @param config The profile configuration.
* @param socksPort The port information.
* @return The generated configuration file.
*/
private fun genConfigHy2(context: Context, config: ProfileItem, socksPort: Int): File? {
Log.i(AppConfig.TAG, "runPlugin $HYSTERIA2")
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort) ?: return null
val configFile = File(context.noBackupFilesDir, "hy2_${SystemClock.elapsedRealtime()}.json")
Log.i(AppConfig.TAG, "runPlugin ${configFile.absolutePath}")
configFile.parentFile?.mkdirs()
configFile.writeText(JsonUtil.toJson(hy2Config))
Log.i(AppConfig.TAG, JsonUtil.toJson(hy2Config))
return configFile
}
/**
* Generate the command to run Hysteria2.
*
* @param context The context to use.
* @param configFile The configuration file.
* @return The command to run Hysteria2.
*/
private fun genCmdHy2(context: Context, configFile: File): MutableList<String> {
return mutableListOf(
File(context.applicationInfo.nativeLibraryDir, HYSTERIA2).absolutePath,
"--disable-update-check",
"--config",
configFile.absolutePath,
"--log-level",
"warn",
"client"
)
}
/**
* Stop the Hysteria2 process.
*/
private fun stopHy2() {
try {
Log.i(AppConfig.TAG, "$HYSTERIA2 destroy")
procService.stopProcess()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop Hysteria2 process", e)
}
}
}
@@ -10,6 +10,7 @@ import com.v2ray.ang.AppConfig.ANG_PACKAGE
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.AppConfig.VPN
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.Language import com.v2ray.ang.dto.Language
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
@@ -386,6 +387,15 @@ 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.
*/ */
@@ -108,7 +108,7 @@ 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 intent = if (SettingsManager.isVpnMode()) {
Intent(context.applicationContext, V2RayVpnService::class.java) Intent(context.applicationContext, V2RayVpnService::class.java)
} else { } else {
Intent(context.applicationContext, V2RayProxyOnlyService::class.java) Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
@@ -168,7 +168,6 @@ object V2RayServiceManager {
NotificationManager.showNotification(currentConfig) NotificationManager.showNotification(currentConfig)
NotificationManager.startSpeedNotification(currentConfig) NotificationManager.startSpeedNotification(currentConfig)
PluginServiceManager.runPlugin(service, config, result.socksPort)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to startup service", e) Log.e(AppConfig.TAG, "Failed to startup service", e)
return false return false
@@ -202,7 +201,6 @@ object V2RayServiceManager {
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e) Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
} }
PluginServiceManager.stopPlugin()
return true return true
} }
@@ -3,6 +3,7 @@ package com.v2ray.ang.handler
import android.content.Context import android.content.Context
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
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.EConfigType
@@ -16,6 +17,7 @@ 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.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.HttpFmt import com.v2ray.ang.fmt.HttpFmt
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt import com.v2ray.ang.fmt.ShadowsocksFmt
import com.v2ray.ang.fmt.SocksFmt import com.v2ray.ang.fmt.SocksFmt
import com.v2ray.ang.fmt.TrojanFmt import com.v2ray.ang.fmt.TrojanFmt
@@ -43,7 +45,7 @@ object V2rayConfigManager {
try { try {
val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false) val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
return if (config.configType == EConfigType.CUSTOM) { return if (config.configType == EConfigType.CUSTOM) {
getV2rayCustomConfig(guid, config) getV2rayCustomConfig(context, guid, config)
} else if (config.configType == EConfigType.POLICYGROUP) { } else if (config.configType == EConfigType.POLICYGROUP) {
getV2rayGroupConfig(context, guid, config) getV2rayGroupConfig(context, guid, config)
} else { } else {
@@ -66,7 +68,7 @@ object V2rayConfigManager {
try { try {
val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false) val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
return if (config.configType == EConfigType.CUSTOM) { return if (config.configType == EConfigType.CUSTOM) {
getV2rayCustomConfig(guid, config) getV2rayCustomConfig(context, guid, config)
} else if (config.configType == EConfigType.POLICYGROUP) { } else if (config.configType == EConfigType.POLICYGROUP) {
// The number of policy groups will not be very large, so no special handling is needed. // The number of policy groups will not be very large, so no special handling is needed.
getV2rayGroupConfig(context, guid, config) getV2rayGroupConfig(context, guid, config)
@@ -86,9 +88,43 @@ object V2rayConfigManager {
* @param config The profile item containing the configuration details. * @param config The profile item containing the configuration details.
* @return A ConfigResult object containing the result of the configuration retrieval. * @return A ConfigResult object containing the result of the configuration retrieval.
*/ */
private fun getV2rayCustomConfig(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)
return ConfigResult(true, guid, raw) val result = ConfigResult(true, guid, raw)
if (!needTun()) {
return result
}
// check if tun inbound exists
val json = JsonUtil.parseString(raw) ?: return result
val inboundsJson = if (json.has("inbounds") && json.get("inbounds")?.isJsonNull == false) {
json.getAsJsonArray("inbounds")
} else {
JsonArray()
}
for (i in 0 until inboundsJson.size()) {
val elem = inboundsJson.get(i)
if (elem.isJsonObject) {
val inb = elem.asJsonObject
val tag = if (inb.has("tag") && inb.get("tag")?.isJsonNull == false) inb.get("tag").asString else ""
if (tag == "tun") return result
}
}
// add tun inbound from template
val templateConfig = initV2rayConfig(context) ?: return result
val inboundTun = templateConfig.inbounds.firstOrNull { it.tag == "tun" } ?: return result
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
// add to json
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
if (inboundsJson.size() == 1) {
json.add("inbounds", inboundsJson)
}
val updatedRaw = JsonUtil.toJsonPretty(json) ?: return result
return ConfigResult(true, guid, updatedRaw)
} }
/** /**
@@ -160,12 +196,8 @@ object V2rayConfigManager {
getInbounds(v2rayConfig) getInbounds(v2rayConfig)
if (config.configType == EConfigType.HYSTERIA2) { getOutbounds(v2rayConfig, config) ?: return result
result.socksPort = getPlusOutbounds(v2rayConfig, config) ?: return result getMoreOutbounds(v2rayConfig, config.subscriptionId)
} else {
getOutbounds(v2rayConfig, config) ?: return result
getMoreOutbounds(v2rayConfig, config.subscriptionId)
}
getRouting(v2rayConfig) getRouting(v2rayConfig)
@@ -196,7 +228,6 @@ object V2rayConfigManager {
val validConfigs = configList.asSequence().filter { it.server.isNotNullEmpty() } val validConfigs = configList.asSequence().filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) } .filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM } .filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.HYSTERIA2 }
.filter { it.configType != EConfigType.POLICYGROUP } .filter { it.configType != EConfigType.POLICYGROUP }
.toList() .toList()
@@ -270,12 +301,8 @@ object V2rayConfigManager {
val v2rayConfig = initV2rayConfig(context) ?: return result val v2rayConfig = initV2rayConfig(context) ?: return result
if (config.configType == EConfigType.HYSTERIA2) { getOutbounds(v2rayConfig, config) ?: return result
result.socksPort = getPlusOutbounds(v2rayConfig, config) ?: return result getMoreOutbounds(v2rayConfig, config.subscriptionId)
} else {
getOutbounds(v2rayConfig, config) ?: return result
getMoreOutbounds(v2rayConfig, config.subscriptionId)
}
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning" v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.inbounds.clear() v2rayConfig.inbounds.clear()
@@ -307,18 +334,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
@@ -330,6 +357,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.
* *
@@ -368,9 +399,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)
@@ -492,23 +524,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
@@ -667,44 +701,6 @@ object V2rayConfigManager {
return true return true
} }
/**
* Configures special outbound settings for Hysteria2 protocol.
*
* Creates a SOCKS outbound connection on a free port for protocols requiring special handling.
*
* @param v2rayConfig The V2ray configuration object to be modified
* @param config The profile item containing connection details
* @return The port number for the SOCKS connection, or null if there was an error
*/
private fun getPlusOutbounds(v2rayConfig: V2rayConfig, config: ProfileItem): Int? {
try {
val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
val outboundNew = OutboundBean(
mux = null,
protocol = EConfigType.SOCKS.name.lowercase(),
settings = OutSettingsBean(
servers = listOf(
OutSettingsBean.ServersBean(
address = AppConfig.LOOPBACK,
port = socksPort
)
)
)
)
if (v2rayConfig.outbounds.isNotEmpty()) {
v2rayConfig.outbounds[0] = outboundNew
} else {
v2rayConfig.outbounds.add(outboundNew)
}
return socksPort
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to configure plusOutbound", e)
return null
}
}
/** /**
* Configures additional outbound connections for proxy chaining. * Configures additional outbound connections for proxy chaining.
* *
@@ -1046,9 +1042,10 @@ object V2rayConfigManager {
EConfigType.VLESS -> VlessFmt.toOutbound(profileItem) EConfigType.VLESS -> VlessFmt.toOutbound(profileItem)
EConfigType.TROJAN -> TrojanFmt.toOutbound(profileItem) EConfigType.TROJAN -> TrojanFmt.toOutbound(profileItem)
EConfigType.WIREGUARD -> WireguardFmt.toOutbound(profileItem) EConfigType.WIREGUARD -> WireguardFmt.toOutbound(profileItem)
EConfigType.HYSTERIA2 -> null EConfigType.HYSTERIA2 -> Hysteria2Fmt.toOutbound(profileItem)
EConfigType.HTTP -> HttpFmt.toOutbound(profileItem) EConfigType.HTTP -> HttpFmt.toOutbound(profileItem)
EConfigType.POLICYGROUP -> null EConfigType.POLICYGROUP -> null
else -> null
} }
} }
@@ -1079,8 +1076,7 @@ object V2rayConfigManager {
EConfigType.SHADOWSOCKS, EConfigType.SHADOWSOCKS,
EConfigType.SOCKS, EConfigType.SOCKS,
EConfigType.HTTP, EConfigType.HTTP,
EConfigType.TROJAN, EConfigType.TROJAN ->
EConfigType.HYSTERIA2 ->
return OutboundBean( return OutboundBean(
protocol = configType.name.lowercase(), protocol = configType.name.lowercase(),
settings = OutSettingsBean( settings = OutSettingsBean(
@@ -1098,6 +1094,16 @@ object V2rayConfigManager {
) )
) )
EConfigType.HYSTERIA,
EConfigType.HYSTERIA2 ->
return OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutSettingsBean(
servers = null
),
streamSettings = StreamSettingsBean()
)
EConfigType.CUSTOM -> null EConfigType.CUSTOM -> null
EConfigType.POLICYGROUP -> null EConfigType.POLICYGROUP -> null
} }
@@ -1127,7 +1133,7 @@ object V2rayConfigManager {
val xhttpExtra = profileItem.xhttpExtra val xhttpExtra = profileItem.xhttpExtra
var sni: String? = null var sni: String? = null
streamSettings.network = if (transport.isEmpty()) NetworkType.TCP.type else transport streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
when (streamSettings.network) { when (streamSettings.network) {
NetworkType.TCP.type -> { NetworkType.TCP.type -> {
val tcpSetting = StreamSettingsBean.TcpSettingsBean() val tcpSetting = StreamSettingsBean.TcpSettingsBean()
@@ -1216,6 +1222,27 @@ object V2rayConfigManager {
sni = authority sni = authority
streamSettings.grpcSettings = grpcSetting streamSettings.grpcSettings = grpcSetting
} }
NetworkType.HYSTERIA.type -> {
val hysteriaSetting = StreamSettingsBean.HysteriaSettingsBean(
version = 2,
auth = profileItem.password.orEmpty(),
up = profileItem.bandwidthUp?.ifEmpty { "0" }.orEmpty(),
down = profileItem.bandwidthDown?.ifEmpty { "0" }.orEmpty(),
udphop = null
)
if (profileItem.portHopping.isNotNullEmpty()) {
hysteriaSetting.udphop = StreamSettingsBean.HysteriaSettingsBean.HysteriaUdpHopBean(
port = profileItem.portHopping,
interval = profileItem.portHoppingInterval
?.trim()
?.toIntOrNull()
?.takeIf { it >= 5 }
?: 30
)
}
streamSettings.hysteriaSettings = hysteriaSetting
}
} }
return sni return sni
} }
@@ -35,18 +35,18 @@ object WebDavManager {
} }
/** /**
* Upload a local file to a remote relative path under the configured remoteBasePath. * Upload a local file to a remote file name under the configured remoteBasePath.
* The provided `remoteRelativePath` should be relative (e.g. "backup_ng.zip"). * The provided `remoteFileName` should be a file name (e.g. "backup_ng.zip").
* The method will attempt to create parent directories via MKCOL before PUT. * The method will attempt to create parent directories via MKCOL before PUT.
* *
* @param localFile File to upload. * @param localFile File to upload.
* @param remoteRelativePath Remote path relative to configured remoteBasePath. * @param remoteFileName Remote file name relative to configured remoteBasePath.
* @return true if upload succeeded (HTTP 2xx), false otherwise. * @return true if upload succeeded (HTTP 2xx), false otherwise.
*/ */
suspend fun uploadFile(localFile: File, remoteRelativePath: String): Boolean = withContext(Dispatchers.IO) { suspend fun uploadFile(localFile: File, remoteFileName: String): Boolean = withContext(Dispatchers.IO) {
val remote = buildRemoteUrl(remoteFileName)
try { try {
val cl = client ?: return@withContext false val cl = client ?: return@withContext false
val remote = buildRemoteUrl(remoteRelativePath)
// Ensure parent directories exist // Ensure parent directories exist
val dirPath = remote.substringBeforeLast('/') val dirPath = remote.substringBeforeLast('/')
@@ -67,14 +67,14 @@ object WebDavManager {
cl.newCall(req).execute().use { resp -> cl.newCall(req).execute().use { resp ->
val success = resp.isSuccessful val success = resp.isSuccessful
if (success) { if (success) {
Log.i(AppConfig.TAG, "WebDAV upload success: $remoteRelativePath") Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
} else { } else {
Log.e(AppConfig.TAG, "WebDAV upload failed: $remoteRelativePath (HTTP ${resp.code})") Log.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
} }
return@withContext success return@withContext success
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload exception: $remoteRelativePath", e) Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
return@withContext false return@withContext false
} }
} }
@@ -82,18 +82,18 @@ object WebDavManager {
/** /**
* Download a remote file (relative to configured remoteBasePath) into a local file. * Download a remote file (relative to configured remoteBasePath) into a local file.
* *
* @param remoteRelativePath Remote path relative to configured remoteBasePath. * @param remoteFileName Remote file name relative to configured remoteBasePath.
* @param destFile Local destination file to write to. * @param destFile Local destination file to write to.
* @return true if download and write succeeded, false otherwise. * @return true if download and write succeeded, false otherwise.
*/ */
suspend fun downloadFile(remoteRelativePath: String, destFile: File): Boolean = withContext(Dispatchers.IO) { suspend fun downloadFile(remoteFileName: String, destFile: File): Boolean = withContext(Dispatchers.IO) {
val remote = buildRemoteUrl(remoteFileName)
try { try {
val cl = client ?: return@withContext false val cl = client ?: return@withContext false
val remote = buildRemoteUrl(remoteRelativePath)
val req = applyAuth(Request.Builder().url(remote).get()).build() val req = applyAuth(Request.Builder().url(remote).get()).build()
cl.newCall(req).execute().use { resp -> cl.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
Log.e(AppConfig.TAG, "WebDAV download failed: $remoteRelativePath (HTTP ${resp.code})") Log.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
return@withContext false return@withContext false
} }
@@ -104,29 +104,31 @@ object WebDavManager {
} }
} }
Log.i(AppConfig.TAG, "WebDAV download success: $remoteRelativePath") Log.i(AppConfig.TAG, "WebDAV download success: $remote")
return@withContext true return@withContext true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV download exception: $remoteRelativePath", e) Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
return@withContext false return@withContext false
} }
} }
/** /**
* Build a full remote URL by combining the configured base URL, the configured * Build a full remote URL by combining the configured base URL, the configured
* remote base path and a relative path provided by the caller. * remote base path and a file name provided by the caller.
* *
* Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups", * Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups",
* remoteRelativePath="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip" * remoteFileName="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip"
* *
* @param remoteRelativePath A path relative to the configured remoteBasePath (no leading slash required). * @param remoteFileName A file name relative to the configured remoteBasePath (no leading slash required).
* @return Full URL string used for HTTP operations. * @return Full URL string used for HTTP operations.
*/ */
private fun buildRemoteUrl(remoteRelativePath: String): String { private fun buildRemoteUrl(remoteFileName: String): String {
val base = cfg?.baseUrl?.trimEnd('/') ?: "" val base = cfg?.baseUrl?.trimEnd('/') ?: ""
val basePath = cfg?.remoteBasePath?.trim('/') ?: "" // Use configured remoteBasePath when not empty; otherwise fallback to AppConfig.WEBDAV_BACKUP_DIR
val rel = remoteRelativePath.trimStart('/') val basePathConfigured = cfg?.remoteBasePath?.trim('/')?.takeIf { it.isNotEmpty() }
val basePath = basePathConfigured ?: AppConfig.WEBDAV_BACKUP_DIR
val rel = remoteFileName.trimStart('/')
return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel" return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel"
} }
@@ -170,7 +172,7 @@ object WebDavManager {
Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}") Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
} }
} }
} catch (ignored: Exception) { } catch (_: Exception) {
// best-effort, continue // best-effort, continue
} }
} }
@@ -1,32 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.pm.ResolveInfo
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
init {
check(resolveInfo.providerInfo != null)
}
override val componentInfo get() = resolveInfo.providerInfo!!
}
@@ -1,43 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.graphics.drawable.Drawable
abstract class Plugin {
abstract val id: String
abstract val label: CharSequence
abstract val version: Int
abstract val versionName: String
open val icon: Drawable? get() = null
open val defaultConfig: String? get() = null
open val packageName: String get() = ""
open val directBootAware: Boolean get() = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return id == (other as Plugin).id
}
override fun hashCode() = id.hashCode()
}
@@ -1,33 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
object PluginContract {
const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN"
const val EXTRA_ENTRY = "io.nekohasekai.sagernet.plugin.EXTRA_ENTRY"
const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id"
const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path"
const val METHOD_GET_EXECUTABLE = "sagernet:getExecutable"
const val COLUMN_PATH = "path"
const val COLUMN_MODE = "mode"
const val SCHEME = "plugin"
}
@@ -1,54 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.Intent
import android.content.pm.PackageManager
import com.v2ray.ang.AngApplication
class PluginList : ArrayList<Plugin>() {
init {
addAll(
AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
)
.filter { it.providerInfo.exported }.map { NativePlugin(it) })
}
val lookup = mutableMapOf<String, Plugin>().apply {
for (plugin in this@PluginList.toList()) {
fun check(old: Plugin?) {
if (old != null && old != plugin) {
this@PluginList.remove(old)
}
/* if (old != null && old !== plugin) {
val packages = this@PluginList.filter { it.id == plugin.id }
.joinToString { it.packageName }
val message = "Conflicting plugins found from: $packages"
Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show()
throw IllegalStateException(message)
}*/
}
check(put(plugin.id, plugin))
}
}
}
@@ -1,232 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-AngApplication@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.system.Os
import androidx.core.os.bundleOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.extension.listenForPackageChanges
import com.v2ray.ang.extension.toast
import com.v2ray.ang.plugin.PluginContract.METADATA_KEY_ID
import java.io.File
import java.io.FileNotFoundException
object PluginManager {
class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin)
private var receiver: BroadcastReceiver? = null
private var cachedPlugins: PluginList? = null
fun fetchPlugins() = synchronized(this) {
if (receiver == null) receiver = AngApplication.application.listenForPackageChanges {
synchronized(this) {
receiver = null
cachedPlugins = null
}
}
if (cachedPlugins == null) cachedPlugins = PluginList()
cachedPlugins!!
}
private fun buildUri(id: String, authority: String) = Uri.Builder()
.scheme(PluginContract.SCHEME)
.authority(authority)
.path("/$id")
.build()
data class InitResult(
val path: String,
)
@Throws(Throwable::class)
fun init(pluginId: String): InitResult? {
if (pluginId.isEmpty()) return null
var throwable: Throwable? = null
try {
val result = initNative(pluginId)
if (result != null) return result
} catch (t: Throwable) {
if (throwable == null) throwable = t //Logs.w(t)
}
throw throwable ?: PluginNotFoundException(pluginId)
}
private fun initNative(pluginId: String): InitResult? {
var flags = PackageManager.GET_META_DATA
if (Build.VERSION.SDK_INT >= 24) {
flags =
flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
}
var providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "com.github.dyhkwong.AngApplication")), flags
)
.filter { it.providerInfo.exported }
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.AngApplication")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "fr.husi")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
).filter {
it.providerInfo.exported &&
it.providerInfo.metaData.containsKey(METADATA_KEY_ID) &&
it.providerInfo.metaData.getString(METADATA_KEY_ID) == pluginId
}
if (providers.size > 1) {
providers = listOf(providers[0]) // What if there is more than one?
}
}
if (providers.isEmpty()) return null
if (providers.size > 1) {
val message =
"Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
AngApplication.application.toast(message)
throw IllegalStateException(message)
}
val provider = providers.single().providerInfo
var failure: Throwable? = null
try {
initNativeFaster(provider)?.also { return InitResult(it) }
} catch (t: Throwable) {
// Logs.w("Initializing native plugin faster mode failed")
failure = t
}
val uri = Uri.Builder().apply {
scheme(ContentResolver.SCHEME_CONTENT)
authority(provider.authority)
}.build()
try {
return initNativeFast(
AngApplication.application.contentResolver,
pluginId,
uri
)?.let { InitResult(it) }
} catch (t: Throwable) {
// Logs.w("Initializing native plugin fast mode failed")
failure?.also { t.addSuppressed(it) }
failure = t
}
try {
return initNativeSlow(
AngApplication.application.contentResolver,
pluginId,
uri
)?.let { InitResult(it) }
} catch (t: Throwable) {
failure.also { t.addSuppressed(it) }
throw t
}
}
private fun initNativeFaster(provider: ProviderInfo): String? {
return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)
?.let { relativePath ->
File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
check(canExecute())
}.absolutePath
}
}
private fun initNativeFast(cr: ContentResolver, pluginId: String, uri: Uri): String? {
return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf())
?.getString(PluginContract.EXTRA_ENTRY)?.also {
check(File(it).canExecute())
}
}
@SuppressLint("Recycle")
private fun initNativeSlow(cr: ContentResolver, pluginId: String, uri: Uri): String? {
var initialized = false
fun entryNotFound(): Nothing =
throw IndexOutOfBoundsException("Plugin entry binary not found")
val pluginDir = File(AngApplication.application.noBackupFilesDir, "plugin")
(cr.query(
uri,
arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE),
null,
null,
null
)
?: return null).use { cursor ->
if (!cursor.moveToFirst()) entryNotFound()
pluginDir.deleteRecursively()
if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory")
val pluginDirPath = pluginDir.absolutePath + '/'
do {
val path = cursor.getString(0)
val file = File(pluginDir, path)
check(file.absolutePath.startsWith(pluginDirPath))
cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream ->
file.outputStream().use { outStream -> inStream.copyTo(outStream) }
}
Os.chmod(
file.absolutePath, when (cursor.getType(1)) {
Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1)
Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8)
else -> throw IllegalArgumentException("File mode should be of type int")
}
)
if (path == pluginId) initialized = true
} while (cursor.moveToNext())
}
if (!initialized) entryNotFound()
return File(pluginDir, pluginId).absolutePath
}
fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) {
is String -> value
// is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
// .getString(value)
null -> null
}
}
@@ -1,51 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.os.Build
import com.v2ray.ang.AngApplication
import com.v2ray.ang.plugin.PluginManager.loadString
abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
protected abstract val componentInfo: ComponentInfo
override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
override val version by lazy {
getPackageInfo(componentInfo.packageName).versionCode
}
override val versionName: String by lazy {
getPackageInfo(componentInfo.packageName).versionName!!
}
override val label: CharSequence get() = resolveInfo.loadLabel(AngApplication.application.packageManager)
override val icon: Drawable get() = resolveInfo.loadIcon(AngApplication.application.packageManager)
override val packageName: String get() = componentInfo.packageName
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
fun getPackageInfo(packageName: String) = AngApplication.application.packageManager.getPackageInfo(
packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
)!!
}
@@ -0,0 +1,85 @@
package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
/**
* Worker that runs a batch of real-ping tests independently.
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
*/
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
private val onFinish: (status: String) -> Unit = {}
) {
private val job = SupervisorJob()
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
private val runningCount = AtomicInteger(0)
private val totalCount = AtomicInteger(0)
fun start() {
val jobs = guids.map { guid ->
totalCount.incrementAndGet()
scope.launch {
runningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
}
}
}
scope.launch {
try {
joinAll(*jobs.toTypedArray())
onFinish("0")
} catch (_: CancellationException) {
onFinish("-1")
} finally {
close()
}
}
}
fun cancel() {
job.cancel()
}
private fun close() {
try {
dispatcher.close()
} catch (_: Throwable) {
// ignore
}
}
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
@@ -6,37 +6,15 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_SUCCESS
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.extension.serializable import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.PluginServiceManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayNativeManager import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.MessageUtil
import kotlinx.coroutines.CancellationException import java.util.Collections
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
class V2RayTestService : Service() { class V2RayTestService : Service() {
private val realTestJob = SupervisorJob()
private val realDispatcher = Dispatchers.IO.limitedParallelism(
Runtime.getRuntime().availableProcessors() * 3
)
private val realTestScope = CoroutineScope(
realTestJob + realDispatcher + CoroutineName("RealTest")
)
// simple counter for currently running tasks // manage active batch workers so each batch is independent and cancellable
private val realTestRunningCount = AtomicInteger(0) private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
private val realTestCount = AtomicInteger(0)
/** /**
* Initializes the V2Ray environment. * Initializes the V2Ray environment.
@@ -60,7 +38,10 @@ class V2RayTestService : Service() {
*/ */
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
realTestJob.cancel() // cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
} }
/** /**
@@ -75,70 +56,24 @@ class V2RayTestService : Service() {
MSG_MEASURE_CONFIG -> { MSG_MEASURE_CONFIG -> {
val guidsList = intent.serializable<ArrayList<String>>("content") val guidsList = intent.serializable<ArrayList<String>>("content")
if (guidsList != null && guidsList.isNotEmpty()) { if (guidsList != null && guidsList.isNotEmpty()) {
startBatchRealPing(guidsList) lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(this, guidsList) { status ->
// notify UI and remove the worker from active list when finished
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
activeWorkers.remove(worker)
}
activeWorkers.add(worker)
worker.start()
} }
} }
MSG_MEASURE_CONFIG_CANCEL -> { MSG_MEASURE_CONFIG_CANCEL -> {
realTestJob.cancelChildren() // cancel all running batch workers independently
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
} }
} }
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
/**
* Starts batch real ping tests.
* @param guidsList The list of GUIDs to test.
*/
private fun startBatchRealPing(guidsList: List<String>) {
val jobs = guidsList.map { guid ->
realTestCount.incrementAndGet()
realTestScope.launch {
realTestRunningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} finally {
val count = realTestCount.decrementAndGet()
val left = realTestRunningCount.decrementAndGet()
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
}
}
}
realTestScope.launch {
try {
joinAll(*jobs.toTypedArray())
notifyAllTasksCompleted("0")
} catch (_: CancellationException) {
notifyAllTasksCompleted("-1")
}
}
}
private fun notifyAllTasksCompleted(status: String) {
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
}
/**
* Starts the real ping test.
* @param guid The GUID of the configuration.
* @return The ping result.
*/
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
if (config.configType == EConfigType.HYSTERIA2) {
val delay = PluginServiceManager.realPingHy2(this, config)
return delay
} else {
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(this, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
} }
@@ -14,6 +14,7 @@ import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WEBDAV_BACKUP_FILE_NAME
import com.v2ray.ang.BuildConfig import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBackupBinding import com.v2ray.ang.databinding.ActivityBackupBinding
@@ -40,10 +41,6 @@ class BackupActivity : BaseActivity() {
resources.getStringArray(R.array.config_backup_options) resources.getStringArray(R.array.config_backup_options)
} }
companion object {
private const val BACKUP_FILE_NAME = "backup_ng.zip"
}
private val requestPermissionLauncher = private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) { if (isGranted) {
@@ -263,7 +260,7 @@ class BackupActivity : BaseActivity() {
WebDavManager.init(saved) WebDavManager.init(saved)
val ok = try { val ok = try {
WebDavManager.uploadFile(tempFile, BACKUP_FILE_NAME) WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "WebDAV upload error", e) Log.e(AppConfig.TAG, "WebDAV upload error", e)
false false
@@ -303,7 +300,7 @@ class BackupActivity : BaseActivity() {
try { try {
target = File(cacheDir, "download_${System.currentTimeMillis()}.zip") target = File(cacheDir, "download_${System.currentTimeMillis()}.zip")
WebDavManager.init(saved) WebDavManager.init(saved)
val ok = WebDavManager.downloadFile(BACKUP_FILE_NAME, target) val ok = WebDavManager.downloadFile(WEBDAV_BACKUP_FILE_NAME, target)
if (!ok) { if (!ok) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
toastError(R.string.toast_failure) toastError(R.string.toast_failure)
@@ -351,7 +348,7 @@ class BackupActivity : BaseActivity() {
val url = dialogBinding.etWebdavUrl.text.toString().trim() val url = dialogBinding.etWebdavUrl.text.toString().trim()
val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null } val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
val pass = dialogBinding.etWebdavPass.text.toString() val pass = dialogBinding.etWebdavPass.text.toString()
val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { "/" } val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath) val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
MmkvManager.encodeWebDavConfig(cfg) MmkvManager.encodeWebDavConfig(cfg)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
@@ -0,0 +1,32 @@
package com.v2ray.ang.ui
/**
* 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()
}
@@ -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,7 +1,6 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -9,7 +8,6 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
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.databinding.FragmentGroupServerBinding import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -51,14 +49,14 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
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 -> mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
adapter.isRunning = isRunning adapter.isRunning = isRunning
} }
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId") // Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
} }
override fun onResume() { override fun onResume() {
@@ -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)
@@ -25,7 +25,6 @@ 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.dto.EConfigType
@@ -34,6 +33,7 @@ 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
@@ -115,52 +115,20 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
setupToolbar(binding.toolbar,false, getString(R.string.title_server)) setupToolbar(binding.toolbar, false, getString(R.string.title_server))
binding.fab.setOnClickListener {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
val intent = VpnService.prepare(this)
if (intent == null) {
startV2Ray()
} else {
requestVpnPermission.launch(intent)
}
} else {
startV2Ray()
}
}
binding.layoutTest.setOnClickListener {
if (mainViewModel.isRunning.value == true) {
setTestState(getString(R.string.connection_test_testing))
mainViewModel.testCurrentServerRealPing()
} else {
// tv_test_state.text = getString(R.string.connection_test_fail)
}
}
// setup viewpager and tablayout
groupPagerAdapter = GroupPagerAdapter(this, emptyList()) groupPagerAdapter = GroupPagerAdapter(this, emptyList())
binding.viewPager.adapter = groupPagerAdapter binding.viewPager.adapter = groupPagerAdapter
binding.viewPager.isUserInputEnabled = true binding.viewPager.isUserInputEnabled = true
// setup navigation drawer
val toggle = ActionBarDrawerToggle( val toggle = ActionBarDrawerToggle(
this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
) )
binding.drawerLayout.addDrawerListener(toggle) binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState() toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this) binding.navView.setNavigationItemSelectedListener(this)
setupGroupTab()
setupViewModel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.POST_NOTIFICATIONS
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
@@ -172,25 +140,26 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
}) })
binding.fab.setOnClickListener { handleFabAction() }
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
setupGroupTab()
setupViewModel()
mainViewModel.reloadServerList() mainViewModel.reloadServerList()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.POST_NOTIFICATIONS
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
} }
private fun setupViewModel() { private fun setupViewModel() {
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) } mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.isRunning.observe(this) { isRunning -> mainViewModel.isRunning.observe(this) { isRunning ->
if (isRunning) { applyRunningState(false, isRunning)
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
binding.layoutTest.isFocusable = false
}
} }
mainViewModel.startListenBroadcast() mainViewModel.startListenBroadcast()
mainViewModel.initAssets(assets) mainViewModel.initAssets(assets)
@@ -214,6 +183,32 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
binding.tabGroup.isVisible = groups.size > 1 binding.tabGroup.isVisible = groups.size > 1
} }
private fun handleFabAction() {
applyRunningState(isLoading = true, isRunning = false)
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
} else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this)
if (intent == null) {
startV2Ray()
} else {
requestVpnPermission.launch(intent)
}
} else {
startV2Ray()
}
}
private fun handleLayoutTestClick() {
if (mainViewModel.isRunning.value == true) {
setTestState(getString(R.string.connection_test_testing))
mainViewModel.testCurrentServerRealPing()
} else {
// service not running: keep existing no-op (could show a message if desired)
}
}
private fun startV2Ray() { private fun startV2Ray() {
if (MmkvManager.getSelectServer().isNullOrEmpty()) { if (MmkvManager.getSelectServer().isNullOrEmpty()) {
toast(R.string.title_file_chooser) toast(R.string.title_file_chooser)
@@ -232,6 +227,31 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
private fun setTestState(content: String?) {
binding.tvTestState.text = content
}
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
if (isLoading) {
binding.fab.setImageResource(R.drawable.ic_fab_check)
return
}
if (isRunning) {
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.tasker_start_service)
setTestState(getString(R.string.connection_not_connected))
binding.layoutTest.isFocusable = false
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
} }
@@ -613,19 +633,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} }
} }
private fun setTestState(content: String?) {
binding.tvTestState.text = content
}
// val mConnection = object : ServiceConnection {
// override fun onServiceDisconnected(name: ComponentName?) {
// }
//
// override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// sendMsg(AppConfig.MSG_REGISTER_CLIENT, "")
// }
// }
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) { if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
moveTaskToBack(false) moveTaskToBack(false)
@@ -30,6 +30,7 @@ import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Collections
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter { class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
companion object { companion object {
@@ -280,21 +281,22 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
* @param position The position in the list * @param position The position in the list
*/ */
private fun removeServer(guid: String, position: Int) { private fun removeServer(guid: String, position: Int) {
if (guid != MmkvManager.getSelectServer()) { if (guid == MmkvManager.getSelectServer()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
} else {
application.toast(R.string.toast_action_not_allowed) 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)
} }
} }
@@ -305,8 +307,12 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
*/ */
private fun removeServerSub(guid: String, position: Int) { private fun removeServerSub(guid: String, position: Int) {
mActivity.mainViewModel.removeServer(guid) mActivity.mainViewModel.removeServer(guid)
notifyItemRemoved(position) val idx = data.indexOfFirst { it.guid == guid }
notifyItemRangeChanged(position, data.size) if (idx >= 0) {
data.removeAt(idx)
notifyItemRemoved(idx)
notifyItemRangeChanged(idx, data.size - idx)
}
} }
/** /**
@@ -364,6 +370,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean { override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
mActivity.mainViewModel.swapServer(fromPosition, toPosition) mActivity.mainViewModel.swapServer(fromPosition, toPosition)
if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition)
}
notifyItemMoved(fromPosition, toPosition) notifyItemMoved(fromPosition, toPosition)
return true return true
} }
@@ -31,7 +31,8 @@ import kotlinx.coroutines.withContext
class RoutingSettingActivity : BaseActivity() { class RoutingSettingActivity : BaseActivity() {
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
@@ -57,7 +58,7 @@ class RoutingSettingActivity : BaseActivity() {
//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)
@@ -202,4 +203,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,6 +1,5 @@
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
@@ -13,12 +12,11 @@ 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 +31,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 +71,7 @@ class RoutingSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
@@ -13,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts
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.databinding.ActivityNoneBinding
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.QRCodeDecoder import com.v2ray.ang.util.QRCodeDecoder
@@ -21,7 +22,7 @@ import io.github.g00fy2.quickie.ScanCustomCode
import io.github.g00fy2.quickie.config.ScannerConfig import io.github.g00fy2.quickie.config.ScannerConfig
class ScannerActivity : BaseActivity() { class ScannerActivity : BaseActivity() {
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()) { private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@@ -59,6 +60,8 @@ class ScannerActivity : BaseActivity() {
public override fun onCreate(savedInstanceState: Bundle?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.menu_item_import_config_qrcode))
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) { if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) {
launchScan() launchScan()
} }
@@ -154,6 +154,7 @@ class ServerActivity : BaseActivity() {
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2 EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
EConfigType.POLICYGROUP -> null EConfigType.POLICYGROUP -> null
else -> null
} ?: return } ?: return
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString()) setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
@@ -21,23 +21,18 @@ import com.v2ray.ang.util.Utils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class SettingsActivity : BaseActivity() { class SettingsActivity : BaseActivity() {
//private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(R.layout.activity_settings)
setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings)) setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings))
//settingsViewModel.startListenPreferenceChange()
} }
class SettingsFragment : PreferenceFragmentCompat() { class SettingsFragment : PreferenceFragmentCompat() {
// private val perAppProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PER_APP_PROXY) }
private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) } private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) } private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) } private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) } private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) } private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) } private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
@@ -55,18 +50,11 @@ class SettingsActivity : BaseActivity() {
private val autoUpdateCheck by lazy { findPreference<CheckBoxPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE) } private val autoUpdateCheck by lazy { findPreference<CheckBoxPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE) }
private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) } private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
// private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
// private val remoteDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_REMOTE_DNS) }
// private val domesticDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DOMESTIC_DNS) }
// private val dnsHosts by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DNS_HOSTS) }
// private val delayTestUrl by lazy { findPreference<EditTextPreference>(AppConfig.PREF_DELAY_TEST_URL) }
// private val ipApiUrl by lazy { findPreference<EditTextPreference>(AppConfig.PREF_IP_API_URL) }
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) } private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
// private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) } private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
// private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) } private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
// private val useTun by lazy { findPreference<ListPreference>(AppConfig.PREF_TUN) } private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
override fun onCreatePreferences(bundle: Bundle?, s: String?) { override fun onCreatePreferences(bundle: Bundle?, s: String?) {
// Use MMKV as the storage backend for all Preferences // Use MMKV as the storage backend for all Preferences
@@ -77,30 +65,10 @@ class SettingsActivity : BaseActivity() {
initPreferenceSummaries() initPreferenceSummaries()
// perAppProxy?.setOnPreferenceClickListener {
// startActivity(Intent(activity, PerAppProxyActivity::class.java))
// perAppProxy?.isChecked = true
// false
// }
localDns?.setOnPreferenceChangeListener { _, any -> localDns?.setOnPreferenceChangeListener { _, any ->
updateLocalDns(any as Boolean) updateLocalDns(any as Boolean)
true true
} }
// localDnsPort?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// localDnsPort?.summary = nval.ifEmpty { AppConfig.PORT_LOCAL_DNS }
// true
// }
// vpnDns?.setOnPreferenceChangeListener { _, any ->
// vpnDns?.summary = any as String
// true
// }
// vpnMtu?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// vpnMtu?.summary = nval.ifEmpty { AppConfig.VPN_MTU.toString() }
// true
// }
mux?.setOnPreferenceChangeListener { _, newValue -> mux?.setOnPreferenceChangeListener { _, newValue ->
updateMux(newValue as Boolean) updateMux(newValue as Boolean)
@@ -119,18 +87,6 @@ class SettingsActivity : BaseActivity() {
updateFragment(newValue as Boolean) updateFragment(newValue as Boolean)
true true
} }
// fragmentPackets?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentPackets(newValue as String)
// true
// }
// fragmentLength?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentLength(newValue as String)
// true
// }
// fragmentInterval?.setOnPreferenceChangeListener { _, newValue ->
// updateFragmentInterval(newValue as String)
// true
// }
autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue -> autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue ->
val value = newValue as Boolean val value = newValue as Boolean
@@ -141,65 +97,21 @@ class SettingsActivity : BaseActivity() {
} }
true true
} }
// autoUpdateInterval?.setOnPreferenceChangeListener { _, any -> mode?.setOnPreferenceChangeListener { pref, newValue ->
// var nval = any as String val valueStr = newValue.toString()
// (pref as? ListPreference)?.let { lp ->
// // It must be greater than 15 minutes because WorkManager couldn't run tasks under 15 minutes intervals val idx = lp.findIndexOfValue(valueStr)
// nval = lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
// if (TextUtils.isEmpty(nval) || nval.toLongEx() < 15) AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL else nval }
// autoUpdateInterval?.summary = nval updateMode(valueStr)
// configureUpdateTask(nval.toLongEx())
// true
// }
// socksPort?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// socksPort?.summary = nval.ifEmpty { AppConfig.PORT_SOCKS }
// true
// }
//
// remoteDns?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// remoteDns?.summary = nval.ifEmpty { AppConfig.DNS_PROXY }
// true
// }
// domesticDns?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// domesticDns?.summary = nval.ifEmpty { AppConfig.DNS_DIRECT }
// true
// }
// dnsHosts?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// dnsHosts?.summary = nval
// true
// }
// delayTestUrl?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// delayTestUrl?.summary = nval.ifEmpty { AppConfig.DELAY_TEST_URL }
// true
// }
// ipApiUrl?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// ipApiUrl?.summary = nval.ifEmpty { AppConfig.IP_API_URL }
// true
// }
mode?.setOnPreferenceChangeListener { _, newValue ->
updateMode(newValue.toString())
true true
} }
mode?.dialogLayoutResource = R.layout.preference_with_help_link mode?.dialogLayoutResource = R.layout.preference_with_help_link
//loglevel.summary = "LogLevel"
// useTun?.setOnPreferenceChangeListener { _, newValue -> useHevTun?.setOnPreferenceChangeListener { _, newValue ->
// updateHevTunSettings(newValue as String == AppConfig.TUN_hevsocks5) updateHevTunSettings(newValue as Boolean)
// true true
// } }
// hevTunRwTimeout?.setOnPreferenceChangeListener { _, any ->
// val nval = any as String
// hevTunRwTimeout?.summary = nval.ifEmpty { AppConfig.HEVTUN_RW_TIMEOUT }
// true
// }
} }
private fun initPreferenceSummaries() { private fun initPreferenceSummaries() {
@@ -212,6 +124,7 @@ class SettingsActivity : BaseActivity() {
true true
} }
} }
is ListPreference -> { is ListPreference -> {
pref.summary = pref.entry ?: "" pref.summary = pref.entry ?: ""
pref.setOnPreferenceChangeListener { p, newValue -> pref.setOnPreferenceChangeListener { p, newValue ->
@@ -221,6 +134,7 @@ class SettingsActivity : BaseActivity() {
true true
} }
} }
is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> { is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> {
} }
} }
@@ -240,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))
@@ -251,101 +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)
// localDns?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
// fakeDns?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
// appendHttpProxy?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
// vpnDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
// vpnMtu?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
// mux?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
// muxConcurrency?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8")
// muxXudpConcurrency?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
// fragment?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
// fragmentPackets?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello")
// fragmentLength?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
// fragmentInterval?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
// autoUpdateCheck?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
// autoUpdateInterval?.summary =
// MmkvManager.decodeSettingsString(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
// socksPort?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
// remoteDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
// domesticDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
// dnsHosts?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
// delayTestUrl?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
// ipApiUrl?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
// hevTunRwTimeout?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
// updateHevTunSettings(MmkvManager.decodeSettingsString(AppConfig.PREF_TUN, AppConfig.TUN_hevsocks5) == AppConfig.TUN_hevsocks5)
// initSharedPreference()
} }
private fun initSharedPreference() { private fun updateMode(value: String?) {
// listOf( val vpn = value == VPN
// //localDnsPort,
// vpnDns,
// vpnMtu,
// muxConcurrency,
// muxXudpConcurrency,
// fragmentLength,
// fragmentInterval,
// autoUpdateInterval,
// socksPort,
// remoteDns,
// domesticDns,
// delayTestUrl,
// ipApiUrl,
// hevTunRwTimeout
// ).forEach { key ->
// key?.summary = key.text.toString()
// }
// listOf(
// AppConfig.PREF_SNIFFING_ENABLED,
// AppConfig.PREF_USE_HEV_TUNNEL
// ).forEach { key ->
// findPreference<CheckBoxPreference>(key)?.isChecked =
// MmkvManager.decodeSettingsBool(key, true)
// }
//
// listOf(
// AppConfig.PREF_ROUTE_ONLY_ENABLED,
// AppConfig.PREF_IS_BOOTED,
// AppConfig.PREF_BYPASS_APPS,
// AppConfig.PREF_SPEED_ENABLED,
// AppConfig.PREF_CONFIRM_REMOVE,
// AppConfig.PREF_START_SCAN_IMMEDIATE,
// AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
// AppConfig.PREF_PREFER_IPV6,
// AppConfig.PREF_PROXY_SHARING,
// AppConfig.PREF_ALLOW_INSECURE
// ).forEach { key ->
// findPreference<CheckBoxPreference>(key)?.isChecked =
// MmkvManager.decodeSettingsBool(key, false)
// }
//
// listOf(
// AppConfig.PREF_VPN_BYPASS_LAN,
// AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX,
// AppConfig.PREF_ROUTING_DOMAIN_STRATEGY,
// AppConfig.PREF_MUX_XUDP_QUIC,
// AppConfig.PREF_FRAGMENT_PACKETS,
// AppConfig.PREF_LANGUAGE,
// AppConfig.PREF_UI_MODE_NIGHT,
// AppConfig.PREF_LOGLEVEL,
// AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD,
// AppConfig.PREF_MODE,
// AppConfig.PREF_HEV_TUNNEL_LOGLEVEL
// ).forEach { key ->
// if (MmkvManager.decodeSettingsString(key) != null) {
// findPreference<ListPreference>(key)?.value = MmkvManager.decodeSettingsString(key)
// }
// }
}
private fun updateMode(mode: String?) {
val vpn = mode == VPN
// perAppProxy?.isEnabled = vpn
// perAppProxy?.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
localDns?.isEnabled = vpn localDns?.isEnabled = vpn
fakeDns?.isEnabled = vpn fakeDns?.isEnabled = vpn
appendHttpProxy?.isEnabled = vpn appendHttpProxy?.isEnabled = vpn
@@ -354,6 +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(
@@ -361,6 +188,12 @@ class SettingsActivity : BaseActivity() {
false false
) )
) )
updateHevTunSettings(
MmkvManager.decodeSettingsBool(
AppConfig.PREF_USE_HEV_TUNNEL,
false
)
)
} }
} }
@@ -423,29 +256,12 @@ class SettingsActivity : BaseActivity() {
fragmentPackets?.isEnabled = enabled fragmentPackets?.isEnabled = enabled
fragmentLength?.isEnabled = enabled fragmentLength?.isEnabled = enabled
fragmentInterval?.isEnabled = enabled fragmentInterval?.isEnabled = enabled
// if (enabled) {
// updateFragmentPackets(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello"))
// updateFragmentLength(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100"))
// updateFragmentInterval(MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20"))
// }
} }
//
// private fun updateFragmentPackets(value: String?) { private fun updateHevTunSettings(enabled: Boolean) {
// fragmentPackets?.summary = value.toString() hevTunLogLevel?.isEnabled = enabled
// } hevTunRwTimeout?.isEnabled = enabled
// }
// private fun updateFragmentLength(value: String?) {
// fragmentLength?.summary = value.toString()
// }
//
// private fun updateFragmentInterval(value: String?) {
// fragmentInterval?.summary = value.toString()
// }
//
// private fun updateHevTunSettings(enabled: Boolean) {
// hevTunLogLevel?.isEnabled = enabled
// hevTunRwTimeout?.isEnabled = enabled
// }
} }
fun onModeHelpClicked(view: View) { fun onModeHelpClicked(view: View) {
@@ -3,18 +3,27 @@ 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.databinding.ActivitySubSettingBinding import com.v2ray.ang.databinding.ActivitySubSettingBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
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
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 +31,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)
@@ -88,4 +101,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,37 +1,22 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.text.TextUtils import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel import com.v2ray.ang.viewmodel.SubscriptionsViewModel
class SubSettingRecyclerAdapter( class SubSettingRecyclerAdapter(
val activity: SubSettingActivity, private val viewModel: SubscriptionsViewModel,
private val viewModel: SubscriptionsViewModel private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter { ) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
private var mActivity: SubSettingActivity = activity
private val share_method: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_sub_method)
}
override fun getItemCount() = viewModel.getAll().size override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
@@ -45,14 +30,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 +54,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 +89,7 @@ class SubSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
@@ -9,44 +9,36 @@ 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 android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
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.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 : BaseActivity() {
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( private val requestStoragePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
@@ -83,13 +75,13 @@ class UserAssetActivity : BaseActivity() {
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 {
@@ -219,81 +211,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 +241,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.first == guid }
var assets = MmkvManager.decodeAssetUrls() ?: viewModel.getAssets().find { it.first == guid }
assets = addBuiltInGeoItems(assets) ?: return
val item = assets.getOrNull(position) ?: return val file = extDir.listFiles()?.find { it.name == asset.second.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,70 @@
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.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.second.remarks }
holder.itemUserAssetBinding.assetName.text = item.second.remarks
if (file != null) {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
holder.itemUserAssetBinding.assetProperties.text =
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
} else {
holder.itemUserAssetBinding.assetProperties.text =
holder.itemUserAssetBinding.root.context.getString(R.string.msg_file_not_found)
}
if (item.second.locked == true) {
holder.itemUserAssetBinding.layoutEdit.visibility = View.GONE
} else {
holder.itemUserAssetBinding.layoutEdit.visibility = if (item.second.url == "file") {
View.GONE
} else {
View.VISIBLE
}
}
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(item.first, position)
}
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(item.first, position)
}
}
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
RecyclerView.ViewHolder(itemUserAssetBinding.root)
}
@@ -375,24 +375,6 @@ object Utils {
} }
} }
/**
* Get the path to the backup directory.
*
* @param context The context to use.
* @return The path to the backup directory.
*/
fun backupPath(context: Context?): String {
if (context == null) return ""
return try {
context.getExternalFilesDir(AppConfig.DIR_BACKUPS)?.absolutePath
?: context.getDir(AppConfig.DIR_BACKUPS, 0).absolutePath
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get backup path", e)
""
}
}
/** /**
* Get the device ID for XUDP base key. * Get the device ID for XUDP base key.
* *
@@ -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) }
}
}
}
@@ -472,7 +472,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
AppConfig.MSG_MEASURE_CONFIG_FINISH -> { AppConfig.MSG_MEASURE_CONFIG_FINISH -> {
onTestsFinished() val content = intent.getStringExtra("content")
if (content == "0") {
onTestsFinished()
}
} }
} }
} }
@@ -0,0 +1,94 @@
package com.v2ray.ang.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig
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<Pair<String, AssetUrlItem>>()
private val builtInGeoFiles = listOf("geosite.dat", "geoip.dat")
val itemCount: Int
get() = assets.size
fun getAssets(): List<Pair<String, AssetUrlItem>> = assets.toList()
fun getAsset(position: Int): Pair<String, AssetUrlItem>? = assets.getOrNull(position)
fun reload(geoFilesSource: String) {
val decoded = MmkvManager.decodeAssetUrls()
assets.clear()
assets.addAll(buildAssetList(decoded, geoFilesSource))
}
private fun buildAssetList(
decodedAssets: List<Pair<String, AssetUrlItem>>?,
geoFilesSource: String
): List<Pair<String, AssetUrlItem>> {
val savedAssets = decodedAssets ?: emptyList()
val builtInItems = builtInGeoFiles
.filter { geoFile -> savedAssets.none { it.second.remarks == geoFile } }
.map {
Utils.getUuid() to AssetUrlItem(
it,
String.format(AppConfig.GITHUB_DOWNLOAD_URL, geoFilesSource).concatUrl(it),
locked = true
)
}
return builtInItems + savedAssets
}
fun downloadGeoFiles(extDir: File, httpPort: Int): GeoDownloadResult {
val snapshot = getAssets()
var successCount = 0
val failures = mutableListOf<String>()
snapshot.forEach { (_, item) ->
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>
)
}
@@ -388,6 +388,7 @@
<item>চায়না ব্ল্যাকলিস্ট</item> <item>চায়না ব্ল্যাকলিস্ট</item>
<item>গ্লোবাল</item> <item>গ্লোবাল</item>
<item>ইরান হোয়াইটলিস্ট</item> <item>ইরান হোয়াইটলিস্ট</item>
<item>রাশিয়া হোয়াইটলিস্ট</item>
</string-array> </string-array>
<string-array name="vpn_bypass_lan"> <string-array name="vpn_bypass_lan">
@@ -398,6 +398,7 @@
<item>نومگه شه چین</item> <item>نومگه شه چین</item>
<item>جهۊوی (Global)</item> <item>جهۊوی (Global)</item>
<item>نومگه اسبؽڌ ایران</item> <item>نومگه اسبؽڌ ایران</item>
<item>Russia Whitelist</item>
</string-array> </string-array>
<string-array name="vpn_bypass_lan"> <string-array name="vpn_bypass_lan">
@@ -397,6 +397,7 @@
<item>لیست سیاه چین</item> <item>لیست سیاه چین</item>
<item>جهانی(GLOBAL)</item> <item>جهانی(GLOBAL)</item>
<item>ایران</item> <item>ایران</item>
<item>لیست سفید روسیه</item>
</string-array> </string-array>
<string-array name="vpn_bypass_lan"> <string-array name="vpn_bypass_lan">
@@ -397,6 +397,7 @@
<item>Чёрный список Китая</item> <item>Чёрный список Китая</item>
<item>Общие</item> <item>Общие</item>
<item>Белый список Ирана</item> <item>Белый список Ирана</item>
<item>Белый список России</item>
</string-array> </string-array>
<string-array name="vpn_bypass_lan"> <string-array name="vpn_bypass_lan">
@@ -389,6 +389,7 @@
<item>黑名单 (Blacklist)</item> <item>黑名单 (Blacklist)</item>
<item>全局 (Global)</item> <item>全局 (Global)</item>
<item>伊朗 (Iran)</item> <item>伊朗 (Iran)</item>
<item>俄罗斯白名单 (Russia Whitelist)</item>
</string-array> </string-array>
<string-array name="vpn_bypass_lan"> <string-array name="vpn_bypass_lan">
@@ -389,6 +389,7 @@
<item>黑名單 (Blacklist)</item> <item>黑名單 (Blacklist)</item>
<item>全域 (Global)</item> <item>全域 (Global)</item>
<item>伊朗 (Iran)</item> <item>伊朗 (Iran)</item>
<item>俄羅斯白名單 (Russia Whitelist)</item>
</string-array> </string-array>
<string-array name="vpn_bypass_lan"> <string-array name="vpn_bypass_lan">
@@ -401,6 +401,7 @@
<item>China Blacklist</item> <item>China Blacklist</item>
<item>Global</item> <item>Global</item>
<item>Iran Whitelist</item> <item>Iran Whitelist</item>
<item>Russia Whitelist</item>
</string-array> </string-array>
<string-array name="vpn_bypass_lan"> <string-array name="vpn_bypass_lan">
Submodule hysteria deleted from 44a5643535
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
targets=(
"aarch64-linux-android24 arm64 arm64-v8a"
"armv7a-linux-androideabi24 arm armeabi-v7a"
"x86_64-linux-android24 amd64 x86_64"
"i686-linux-android24 386 x86"
)
cd "hysteria" || exit
for target in "${targets[@]}"; do
IFS=' ' read -r ndk_target goarch abi <<< "$target"
echo "Building for ${abi} with ${ndk_target} (${goarch})"
CC="${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/${ndk_target}-clang" CGO_ENABLED=1 GOOS=android GOARCH=$goarch go build -o libs/$abi/libhysteria2.so -trimpath -ldflags "-s -w -buildid=" -buildvcs=false ./app
echo "Built libhysteria2.so for ${abi}"
done