Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 659f2ded09 | |||
| 585d64b0be | |||
| 2ae2e747e2 | |||
| 4199cee4e5 | |||
| 8be8a912c4 | |||
| 851f22680a | |||
| 12abc99bfc | |||
| 07e50637a2 | |||
| c1824c7598 | |||
| 18eebf999d | |||
| e37d6b7523 | |||
| 8c5375ec62 |
@@ -70,7 +70,7 @@ jobs:
|
|||||||
popd
|
popd
|
||||||
|
|
||||||
- name: Download libv2ray
|
- name: Download libv2ray
|
||||||
uses: robinraju/release-downloader@v1.12
|
uses: robinraju/release-downloader@v1.13
|
||||||
with:
|
with:
|
||||||
repository: '2dust/AndroidLibXrayLite'
|
repository: '2dust/AndroidLibXrayLite'
|
||||||
tag: ${{ env.CURRENT_TAG }}
|
tag: ${{ env.CURRENT_TAG }}
|
||||||
|
|||||||
+1
-1
Submodule AndroidLibXrayLite updated: 203f317afd...071ff37317
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "com.v2ray.ang"
|
applicationId = "com.v2ray.ang"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 720
|
versionCode = 722
|
||||||
versionName = "2.1.0"
|
versionName = "2.1.2"
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||||
|
|||||||
@@ -88,6 +88,9 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".ui.PerAppProxyActivity"
|
android:name=".ui.PerAppProxyActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.AppPickerActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.ScannerActivity"
|
android:name=".ui.ScannerActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ object AppConfig {
|
|||||||
const val REALITY = "reality"
|
const val REALITY = "reality"
|
||||||
const val HEADER_TYPE_HTTP = "http"
|
const val HEADER_TYPE_HTTP = "http"
|
||||||
|
|
||||||
|
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
|
||||||
|
|
||||||
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
||||||
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||||
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
|
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
|
||||||
|
|||||||
@@ -532,10 +532,12 @@ object AngConfigManager {
|
|||||||
}
|
}
|
||||||
LogUtil.i(AppConfig.TAG, url)
|
LogUtil.i(AppConfig.TAG, url)
|
||||||
val userAgent = it.subscription.userAgent
|
val userAgent = it.subscription.userAgent
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
|
|
||||||
var configText = try {
|
var configText = try {
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
|
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort, proxyUsername, proxyPassword)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||||
""
|
""
|
||||||
|
|||||||
@@ -317,11 +317,7 @@ object SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun generateRandomSocksPort(): Int {
|
private fun generateRandomSocksPort(): Int {
|
||||||
return if (Utils.isXray()) {
|
return Random.nextInt(10000, 65535)
|
||||||
Random.nextInt(10000, 65536)
|
|
||||||
} else {
|
|
||||||
Random.nextInt(10000, 65535)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -83,49 +83,14 @@ object SpeedtestManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests the connection to a given URL and port.
|
|
||||||
*
|
|
||||||
* @param context The Context in which the test is running.
|
|
||||||
* @param port The port to connect to.
|
|
||||||
* @return A pair containing the elapsed time in milliseconds and the result message.
|
|
||||||
*/
|
|
||||||
fun testConnection(context: Context, port: Int): Pair<Long, String> {
|
|
||||||
var result: String
|
|
||||||
var elapsed = -1L
|
|
||||||
|
|
||||||
val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
|
|
||||||
try {
|
|
||||||
val start = SystemClock.elapsedRealtime()
|
|
||||||
val code = conn.responseCode
|
|
||||||
elapsed = SystemClock.elapsedRealtime() - start
|
|
||||||
|
|
||||||
result = when (code) {
|
|
||||||
204 -> context.getString(R.string.connection_test_available, elapsed)
|
|
||||||
200 if conn.contentLengthLong == 0L -> context.getString(R.string.connection_test_available, elapsed)
|
|
||||||
else -> throw IOException(
|
|
||||||
context.getString(R.string.connection_test_error_status_code, code)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
LogUtil.e(AppConfig.TAG, "Connection test IOException", e)
|
|
||||||
result = context.getString(R.string.connection_test_error, e.message)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtil.e(AppConfig.TAG, "Connection test Exception", e)
|
|
||||||
result = context.getString(R.string.connection_test_error, e.message)
|
|
||||||
} finally {
|
|
||||||
conn.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
return Pair(elapsed, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getRemoteIPInfo(): String? {
|
fun getRemoteIPInfo(): String? {
|
||||||
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
||||||
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
||||||
|
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
val content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: return null
|
val content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: return null
|
||||||
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
|
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
|
||||||
|
|
||||||
val ip = listOf(
|
val ip = listOf(
|
||||||
|
|||||||
@@ -23,10 +23,13 @@ object UpdateCheckerManager {
|
|||||||
AppConfig.APP_API_URL.concatUrl("latest")
|
AppConfig.APP_API_URL.concatUrl("latest")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
|
|
||||||
var response = HttpUtil.getUrlContent(url, 5000)
|
var response = HttpUtil.getUrlContent(url, 5000)
|
||||||
if (response.isNullOrEmpty()) {
|
if (response.isNullOrEmpty()) {
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
response = HttpUtil.getUrlContent(url, 5000, httpPort)
|
response = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword)
|
||||||
?: throw IllegalStateException("Failed to get response")
|
?: throw IllegalStateException("Failed to get response")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,39 +64,6 @@ object UpdateCheckerManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
|
||||||
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
|
|
||||||
?: throw IllegalStateException("Failed to create connection")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val apkFile = File(context.cacheDir, "update.apk")
|
|
||||||
LogUtil.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
|
|
||||||
|
|
||||||
FileOutputStream(apkFile).use { outputStream ->
|
|
||||||
connection.inputStream.use { inputStream ->
|
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LogUtil.i(AppConfig.TAG, "APK download completed")
|
|
||||||
return@withContext apkFile
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
|
|
||||||
return@withContext null
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
connection.disconnect()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtil.e(AppConfig.TAG, "Error closing connection: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compareVersions(version1: String, version2: String): Int {
|
private fun compareVersions(version1: String, version2: String): Int {
|
||||||
val v1 = version1.split(".")
|
val v1 = version1.split(".")
|
||||||
val v2 = version2.split(".")
|
val v2 = version2.split(".")
|
||||||
|
|||||||
@@ -98,36 +98,46 @@ object V2rayConfigManager {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if tun inbound exists
|
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
|
||||||
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()) {
|
// Check whether package names need to be replaced with UIDs
|
||||||
val elem = inboundsJson.get(i)
|
if (SettingsManager.canUseProcessRouting()) {
|
||||||
if (elem.isJsonObject) {
|
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||||
val inb = elem.asJsonObject
|
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||||
val tag = if (inb.has("tag") && inb.get("tag")?.isJsonNull == false) inb.get("tag").asString else ""
|
?: JsonArray()
|
||||||
if (tag == "tun") return result
|
|
||||||
|
for (elem in rulesJson) {
|
||||||
|
val rule = elem.takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||||
|
val process = rule.get("process")?.takeIf { it.isJsonArray }?.asJsonArray ?: continue
|
||||||
|
val packages = process.mapNotNull {
|
||||||
|
it.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
|
||||||
|
}.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val uids = PackageUidResolver.packageNamesToUids(context, packages).takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
|
||||||
|
rule.add("process", JsonArray().apply { uids.forEach { add(it) } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// add tun inbound from template
|
// check if tun inbound exists
|
||||||
val templateConfig = initV2rayConfig(context) ?: return result
|
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||||
val inboundTun = templateConfig.inbounds.firstOrNull { it.tag == "tun" } ?: return result
|
?: JsonArray().also { json.add("inbounds", it) }
|
||||||
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
|
val tunNotExists = inboundsJson.none { elem ->
|
||||||
|
elem.isJsonObject && elem.asJsonObject.get("protocol")
|
||||||
// add to json
|
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
|
||||||
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
|
?.asString == "tun"
|
||||||
if (inboundsJson.size() == 1) {
|
|
||||||
json.add("inbounds", inboundsJson)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val updatedRaw = JsonUtil.toJsonPretty(json) ?: return result
|
if (tunNotExists) {
|
||||||
return ConfigResult(true, guid, updatedRaw)
|
// add tun inbound from template
|
||||||
|
initV2rayConfig(context)?.let { templateConfig ->
|
||||||
|
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
|
||||||
|
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
|
||||||
|
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, guid, it) } ?: result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -416,6 +426,8 @@ object V2rayConfigManager {
|
|||||||
inbound2.tag = EConfigType.HTTP.name.lowercase()
|
inbound2.tag = EConfigType.HTTP.name.lowercase()
|
||||||
inbound2.port = SettingsManager.getHttpPort()
|
inbound2.port = SettingsManager.getHttpPort()
|
||||||
inbound2.protocol = EConfigType.HTTP.name.lowercase()
|
inbound2.protocol = EConfigType.HTTP.name.lowercase()
|
||||||
|
inbound2.settings?.auth = null
|
||||||
|
inbound2.settings?.udp = null
|
||||||
v2rayConfig.inbounds.add(inbound2)
|
v2rayConfig.inbounds.add(inbound2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.appcompat.widget.SearchView
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.databinding.ActivityAppPickerBinding
|
||||||
|
import com.v2ray.ang.dto.AppInfo
|
||||||
|
import com.v2ray.ang.util.AppManagerUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.PackageUidResolver
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.text.Collator
|
||||||
|
|
||||||
|
class AppPickerActivity : BaseActivity() {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
|
||||||
|
private const val EXTRA_PICKER_TITLE = "picker_title"
|
||||||
|
|
||||||
|
fun createIntent(
|
||||||
|
context: Context,
|
||||||
|
selectedPackages: Collection<String> = emptyList(),
|
||||||
|
title: String? = null
|
||||||
|
): Intent = Intent(context, AppPickerActivity::class.java).apply {
|
||||||
|
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(selectedPackages))
|
||||||
|
title?.let { putExtra(EXTRA_PICKER_TITLE, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSelectedPackages(intent: Intent?): List<String> {
|
||||||
|
return intent?.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
|
||||||
|
private val initialSelectedPackages by lazy {
|
||||||
|
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||||
|
}
|
||||||
|
private val selectedPackages = LinkedHashSet<String>()
|
||||||
|
private var appsAll: List<AppInfo> = emptyList()
|
||||||
|
private val adapter = AppSelectorAdapter(selectedPackages)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
|
||||||
|
|
||||||
|
selectedPackages.addAll(initialSelectedPackages)
|
||||||
|
setupRecyclerView()
|
||||||
|
loadApps()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.menu_app_picker, menu)
|
||||||
|
|
||||||
|
val searchItem = menu.findItem(R.id.search_view)
|
||||||
|
if (searchItem != null) {
|
||||||
|
val searchView = searchItem.actionView as SearchView
|
||||||
|
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||||
|
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||||
|
|
||||||
|
override fun onQueryTextChange(newText: String?): Boolean {
|
||||||
|
filterApps(newText.orEmpty())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||||
|
R.id.select_all -> {
|
||||||
|
selectAllVisible()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.invert_selection -> {
|
||||||
|
invertVisibleSelection()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun finish() {
|
||||||
|
setResult(
|
||||||
|
RESULT_OK,
|
||||||
|
Intent().apply {
|
||||||
|
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
super.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupRecyclerView() {
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UseCompatLoadingForDrawables")
|
||||||
|
private fun createSpecialItemUnidentified(): AppInfo {
|
||||||
|
val icon = requireNotNull(
|
||||||
|
getDrawable(android.R.drawable.ic_menu_help)
|
||||||
|
?: getDrawable(android.R.drawable.sym_def_app_icon)
|
||||||
|
) { "No fallback drawable available" }
|
||||||
|
return AppInfo(
|
||||||
|
appName = getString(R.string.app_picker_unknown_app),
|
||||||
|
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
|
||||||
|
appIcon = icon,
|
||||||
|
isSystemApp = false,
|
||||||
|
isSelected = 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadApps() {
|
||||||
|
showLoading()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val apps = withContext(Dispatchers.IO) {
|
||||||
|
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
|
||||||
|
val sortedApps = sortApps(appsList)
|
||||||
|
listOf(createSpecialItemUnidentified()) + sortedApps
|
||||||
|
}
|
||||||
|
|
||||||
|
appsAll = apps
|
||||||
|
updateDisplayedApps(apps)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
|
||||||
|
} finally {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterApps(content: String) {
|
||||||
|
val key = content.uppercase()
|
||||||
|
val filteredApps = appsAll.filter { app ->
|
||||||
|
key.isEmpty() || matchesSearch(app, key)
|
||||||
|
}
|
||||||
|
updateDisplayedApps(filteredApps)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
|
||||||
|
val collator = Collator.getInstance()
|
||||||
|
return apps.sortedWith { p1, p2 ->
|
||||||
|
val p1Selected = selectedPackages.contains(p1.packageName)
|
||||||
|
val p2Selected = selectedPackages.contains(p2.packageName)
|
||||||
|
when {
|
||||||
|
p1Selected && !p2Selected -> -1
|
||||||
|
!p1Selected && p2Selected -> 1
|
||||||
|
p1.isSystemApp && !p2.isSystemApp -> 1
|
||||||
|
!p1.isSystemApp && p2.isSystemApp -> -1
|
||||||
|
else -> collator.compare(p1.appName, p2.appName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
|
||||||
|
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDisplayedApps(apps: List<AppInfo>) {
|
||||||
|
adapter.submitList(apps)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectAllVisible() {
|
||||||
|
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
|
||||||
|
adapter.refreshSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invertVisibleSelection() {
|
||||||
|
adapter.apps.forEach { app ->
|
||||||
|
if (selectedPackages.contains(app.packageName)) {
|
||||||
|
selectedPackages.remove(app.packageName)
|
||||||
|
} else {
|
||||||
|
selectedPackages.add(app.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adapter.refreshSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSelectedPackages(): ArrayList<String> {
|
||||||
|
return ArrayList(selectedPackages.sorted())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveScreenTitle(): String {
|
||||||
|
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
||||||
|
import com.v2ray.ang.dto.AppInfo
|
||||||
|
|
||||||
|
class AppSelectorAdapter(
|
||||||
|
private val selectedPackages: MutableSet<String>
|
||||||
|
) : RecyclerView.Adapter<AppSelectorAdapter.AppViewHolder>() {
|
||||||
|
|
||||||
|
var apps: List<AppInfo> = emptyList()
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
|
||||||
|
val binding = ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
return AppViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = apps.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
|
||||||
|
holder.bind(apps[position])
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitList(newApps: List<AppInfo>) {
|
||||||
|
apps = newApps
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSelection() {
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class AppViewHolder(private val binding: ItemRecyclerBypassListBinding) : RecyclerView.ViewHolder(binding.root),
|
||||||
|
View.OnClickListener {
|
||||||
|
private lateinit var appInfo: AppInfo
|
||||||
|
|
||||||
|
fun bind(item: AppInfo) {
|
||||||
|
appInfo = item
|
||||||
|
binding.icon.setImageDrawable(item.appIcon)
|
||||||
|
binding.name.text = if (item.isSystemApp) {
|
||||||
|
String.format("** %s", item.appName)
|
||||||
|
} else {
|
||||||
|
item.appName
|
||||||
|
}
|
||||||
|
binding.packageName.text = item.packageName
|
||||||
|
binding.checkBox.isChecked = selectedPackages.contains(item.packageName)
|
||||||
|
itemView.setOnClickListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View?) {
|
||||||
|
val packageName = appInfo.packageName
|
||||||
|
if (selectedPackages.contains(packageName)) {
|
||||||
|
selectedPackages.remove(packageName)
|
||||||
|
} else {
|
||||||
|
selectedPackages.add(packageName)
|
||||||
|
}
|
||||||
|
binding.checkBox.isChecked = selectedPackages.contains(packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -191,8 +191,10 @@ class PerAppProxyActivity : BaseActivity() {
|
|||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
var content = HttpUtil.getUrlContent(url, 5000)
|
var content = HttpUtil.getUrlContent(url, 5000)
|
||||||
if (content.isNullOrEmpty()) {
|
if (content.isNullOrEmpty()) {
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
|
content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: ""
|
||||||
}
|
}
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
//LogUtil.i(AppConfig.TAG, content)
|
//LogUtil.i(AppConfig.TAG, content)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
|||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
|
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||||
@@ -23,12 +24,19 @@ import kotlinx.coroutines.launch
|
|||||||
class RoutingEditActivity : BaseActivity() {
|
class RoutingEditActivity : BaseActivity() {
|
||||||
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
|
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
|
||||||
private val position by lazy { intent.getIntExtra("position", -1) }
|
private val position by lazy { intent.getIntExtra("position", -1) }
|
||||||
|
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == RESULT_OK) {
|
||||||
|
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
|
||||||
|
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
||||||
|
|
||||||
setupOutboundTagInput()
|
setupOutboundTagInput()
|
||||||
|
setupProcessPicker()
|
||||||
|
|
||||||
val rulesetItem = SettingsManager.getRoutingRuleset(position)
|
val rulesetItem = SettingsManager.getRoutingRuleset(position)
|
||||||
if (rulesetItem != null) {
|
if (rulesetItem != null) {
|
||||||
@@ -39,9 +47,31 @@ class RoutingEditActivity : BaseActivity() {
|
|||||||
|
|
||||||
SettingsManager.canUseProcessRouting().let { canUse ->
|
SettingsManager.canUseProcessRouting().let { canUse ->
|
||||||
binding.etProcess.isEnabled = canUse
|
binding.etProcess.isEnabled = canUse
|
||||||
|
binding.btnProcessPicker.isEnabled = canUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setupProcessPicker() {
|
||||||
|
binding.btnProcessPicker.setOnClickListener {
|
||||||
|
processPickerLauncher.launch(
|
||||||
|
AppPickerActivity.createIntent(
|
||||||
|
context = this,
|
||||||
|
selectedPackages = getSelectedProcessPackages(),
|
||||||
|
title = getString(R.string.routing_settings_process)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSelectedProcessPackages(): List<String> {
|
||||||
|
return binding.etProcess.text
|
||||||
|
.toString()
|
||||||
|
.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up the AutoCompleteTextView for outbound tag:
|
* Sets up the AutoCompleteTextView for outbound tag:
|
||||||
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
|
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
|
||||||
|
|||||||
@@ -173,9 +173,11 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
showLoading()
|
showLoading()
|
||||||
toast(R.string.msg_downloading_content)
|
toast(R.string.msg_downloading_content)
|
||||||
|
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val result = viewModel.downloadGeoFiles(extDir, httpPort)
|
val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (result.successCount > 0) {
|
if (result.successCount > 0) {
|
||||||
toast(getString(R.string.title_update_config_count, result.successCount))
|
toast(getString(R.string.title_update_config_count, result.successCount))
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ package com.v2ray.ang.util
|
|||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
import com.v2ray.ang.util.Utils.encode
|
import java.io.File
|
||||||
import com.v2ray.ang.util.Utils.urlDecode
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.IDN
|
import java.net.IDN
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
@@ -15,6 +13,10 @@ import java.net.MalformedURLException
|
|||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
object HttpUtil {
|
object HttpUtil {
|
||||||
|
|
||||||
@@ -106,13 +108,31 @@ object HttpUtil {
|
|||||||
* @param httpPort The HTTP port to use.
|
* @param httpPort The HTTP port to use.
|
||||||
* @return The content of the URL as a string.
|
* @return The content of the URL as a string.
|
||||||
*/
|
*/
|
||||||
fun getUrlContent(url: String, timeout: Int, httpPort: Int = 0): String? {
|
fun getUrlContent(
|
||||||
val conn = createProxyConnection(url, httpPort, timeout, timeout) ?: return null
|
url: String,
|
||||||
|
timeout: Int,
|
||||||
|
httpPort: Int = 0,
|
||||||
|
proxyUsername: String? = null,
|
||||||
|
proxyPassword: String? = null
|
||||||
|
): String? {
|
||||||
|
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = true)
|
||||||
|
val requestBuilder = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.header("Connection", "close")
|
||||||
|
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||||
|
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return conn.inputStream.bufferedReader().readText()
|
client.newCall(requestBuilder.build()).execute().use { response ->
|
||||||
} catch (_: Exception) {
|
if (!response.isSuccessful) {
|
||||||
} finally {
|
LogUtil.w(AppConfig.TAG, "Failed to get URL content, code=${response.code}")
|
||||||
conn.disconnect()
|
return null
|
||||||
|
}
|
||||||
|
return response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to get URL content", e)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -127,127 +147,147 @@ object HttpUtil {
|
|||||||
* @throws IOException If an I/O error occurs.
|
* @throws IOException If an I/O error occurs.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getUrlContentWithUserAgent(url: String?, userAgent: String?, timeout: Int = 15000, httpPort: Int = 0): String {
|
fun getUrlContentWithUserAgent(
|
||||||
|
url: String?,
|
||||||
|
userAgent: String?,
|
||||||
|
timeout: Int = 15000,
|
||||||
|
httpPort: Int = 0,
|
||||||
|
proxyUsername: String? = null,
|
||||||
|
proxyPassword: String? = null
|
||||||
|
): String {
|
||||||
var currentUrl = url
|
var currentUrl = url
|
||||||
var redirects = 0
|
var redirects = 0
|
||||||
val maxRedirects = 3
|
val maxRedirects = 3
|
||||||
|
|
||||||
while (redirects++ < maxRedirects) {
|
while (redirects++ < maxRedirects) {
|
||||||
if (currentUrl == null) continue
|
if (currentUrl == null) continue
|
||||||
val conn = createProxyConnection(currentUrl, httpPort, timeout, timeout) ?: continue
|
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = false)
|
||||||
val finalUserAgent = if (userAgent.isNullOrBlank()) {
|
val finalUserAgent = if (userAgent.isNullOrBlank()) {
|
||||||
"v2rayNG/${BuildConfig.VERSION_NAME}"
|
"v2rayNG/${BuildConfig.VERSION_NAME}"
|
||||||
} else {
|
} else {
|
||||||
userAgent
|
userAgent
|
||||||
}
|
}
|
||||||
conn.setRequestProperty("User-agent", finalUserAgent)
|
val requestBuilder = Request.Builder()
|
||||||
conn.connect()
|
.url(currentUrl)
|
||||||
|
.get()
|
||||||
|
.header("User-agent", finalUserAgent)
|
||||||
|
.header("Connection", "close")
|
||||||
|
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||||
|
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||||
|
}
|
||||||
|
|
||||||
val responseCode = conn.responseCode
|
client.newCall(requestBuilder.build()).execute().use { response ->
|
||||||
when (responseCode) {
|
when {
|
||||||
in 300..399 -> {
|
response.isRedirect -> {
|
||||||
val location = resolveLocation(conn)
|
val location = response.header("Location")
|
||||||
conn.disconnect()
|
if (location.isNullOrEmpty()) {
|
||||||
if (location.isNullOrEmpty()) {
|
throw IOException("Redirect location not found")
|
||||||
throw IOException("Redirect location not found")
|
}
|
||||||
|
currentUrl = resolveLocation(currentUrl, location)
|
||||||
|
if (currentUrl.isNullOrEmpty()) {
|
||||||
|
throw IOException("Failed to resolve redirect location")
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
currentUrl = location
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> try {
|
response.isSuccessful -> {
|
||||||
return conn.inputStream.use { it.bufferedReader().readText() }
|
return response.body?.string() ?: ""
|
||||||
} finally {
|
}
|
||||||
conn.disconnect()
|
|
||||||
|
else -> {
|
||||||
|
throw IOException("Request failed with status code ${response.code}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw IOException("Too many redirects")
|
throw IOException("Too many redirects")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun buildOkHttpClient(
|
||||||
* Creates an HttpURLConnection object connected through a proxy.
|
timeout: Int,
|
||||||
*
|
httpPort: Int,
|
||||||
* @param urlStr The target URL address.
|
proxyUsername: String?,
|
||||||
* @param port The port of the proxy server.
|
proxyPassword: String?,
|
||||||
* @param connectTimeout The connection timeout in milliseconds (default is 15000 ms).
|
followRedirects: Boolean
|
||||||
* @param readTimeout The read timeout in milliseconds (default is 15000 ms).
|
): OkHttpClient {
|
||||||
* @param needStream Whether the connection needs to support streaming.
|
val builder = OkHttpClient.Builder()
|
||||||
* @return Returns a configured HttpURLConnection object, or null if it fails.
|
.connectTimeout(timeout.toLong(), TimeUnit.MILLISECONDS)
|
||||||
*/
|
.readTimeout(timeout.toLong(), TimeUnit.MILLISECONDS)
|
||||||
fun createProxyConnection(
|
.followRedirects(followRedirects)
|
||||||
urlStr: String,
|
.followSslRedirects(followRedirects)
|
||||||
port: Int,
|
|
||||||
connectTimeout: Int = 15000,
|
|
||||||
readTimeout: Int = 15000,
|
|
||||||
needStream: Boolean = false
|
|
||||||
): HttpURLConnection? {
|
|
||||||
|
|
||||||
var conn: HttpURLConnection? = null
|
if (httpPort != 0) {
|
||||||
try {
|
builder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(LOOPBACK, httpPort)))
|
||||||
val url = URL(urlStr)
|
if (!proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||||
// Create a connection
|
builder.proxyAuthenticator { _, response ->
|
||||||
conn = if (port == 0) {
|
if (response.request.header("Proxy-Authorization") != null) {
|
||||||
url.openConnection()
|
null
|
||||||
} else {
|
} else {
|
||||||
url.openConnection(
|
response.request.newBuilder()
|
||||||
Proxy(
|
.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||||
Proxy.Type.HTTP,
|
.build()
|
||||||
InetSocketAddress(LOOPBACK, port)
|
}
|
||||||
)
|
}
|
||||||
)
|
|
||||||
} as HttpURLConnection
|
|
||||||
|
|
||||||
// Set connection and read timeouts
|
|
||||||
conn.connectTimeout = connectTimeout
|
|
||||||
conn.readTimeout = readTimeout
|
|
||||||
if (!needStream) {
|
|
||||||
// Set request headers
|
|
||||||
conn.setRequestProperty("Connection", "close")
|
|
||||||
// Disable automatic redirects
|
|
||||||
conn.instanceFollowRedirects = false
|
|
||||||
// Disable caching
|
|
||||||
conn.useCaches = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//Add Basic Authorization
|
|
||||||
url.userInfo?.let {
|
|
||||||
conn.setRequestProperty(
|
|
||||||
"Authorization",
|
|
||||||
"Basic ${encode(urlDecode(it))}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to create proxy connection", e)
|
|
||||||
// If an exception occurs, close the connection and return null
|
|
||||||
conn?.disconnect()
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
return conn
|
|
||||||
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns absolute URL string location header sets
|
private fun resolveLocation(baseUrl: String, raw: String): String? {
|
||||||
fun resolveLocation(conn: HttpURLConnection): String? {
|
|
||||||
val raw = conn.getHeaderField("Location")?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
|
||||||
|
|
||||||
// Try check url is relative or absolute
|
|
||||||
return try {
|
return try {
|
||||||
val locUri = URI(raw)
|
val locUri = URI(raw)
|
||||||
val baseUri = conn.url.toURI()
|
val baseUri = URI(baseUrl)
|
||||||
val resolved = if (locUri.isAbsolute) locUri else baseUri.resolve(locUri)
|
val resolved = if (locUri.isAbsolute) locUri else baseUri.resolve(locUri)
|
||||||
resolved.toURL().toString()
|
resolved.toURL().toString()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Fallback: url resolver, also should handles //host/...
|
|
||||||
try {
|
try {
|
||||||
URL(raw).toString() // absolute with protocol
|
URL(raw).toString()
|
||||||
} catch (_: MalformedURLException) {
|
} catch (_: MalformedURLException) {
|
||||||
try {
|
try {
|
||||||
URL(conn.url, raw).toString()
|
URL(URL(baseUrl), raw).toString()
|
||||||
} catch (_: MalformedURLException) {
|
} catch (_: MalformedURLException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun downloadToFile(
|
||||||
|
url: String,
|
||||||
|
targetFile: File,
|
||||||
|
timeout: Int = 15000,
|
||||||
|
httpPort: Int = 0,
|
||||||
|
proxyUsername: String? = null,
|
||||||
|
proxyPassword: String? = null
|
||||||
|
): Boolean {
|
||||||
|
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = true)
|
||||||
|
val requestBuilder = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.header("Connection", "close")
|
||||||
|
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||||
|
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
client.newCall(requestBuilder.build()).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Failed to download file, code=${response.code}, url=$url")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val body = response.body ?: return false
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
targetFile.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to download file: $url", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ package com.v2ray.ang.util
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
object PackageUidResolver {
|
object PackageUidResolver {
|
||||||
|
|
||||||
private const val TAG = "PackageUidResolver"
|
|
||||||
|
|
||||||
// In-process cache to avoid resolving the same package UID repeatedly.
|
// In-process cache to avoid resolving the same package UID repeatedly.
|
||||||
private val packageUidCache = ConcurrentHashMap<String, String>()
|
private val packageUidCache = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
@@ -32,13 +31,20 @@ object PackageUidResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveUid(context: Context, packageName: String): String? {
|
private fun resolveUid(context: Context, packageName: String): String? {
|
||||||
|
// Special token for connections whose UID cannot be resolved (mapped to -1)
|
||||||
|
if (packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
|
||||||
|
val uid = "-1"
|
||||||
|
LogUtil.d(AppConfig.TAG, "Special package: $packageName -> UID: $uid")
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val uid = context.packageManager.getPackageUid(packageName, 0).toString()
|
val uid = context.packageManager.getPackageUid(packageName, 0).toString()
|
||||||
LogUtil.d(TAG, "Package: $packageName -> UID: $uid")
|
LogUtil.d(AppConfig.TAG, "Package: $packageName -> UID: $uid")
|
||||||
uid
|
uid
|
||||||
} catch (_: PackageManager.NameNotFoundException) {
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
LogUtil.w(TAG, "Package not found: $packageName")
|
LogUtil.w(AppConfig.TAG, "Package not found: $packageName")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import com.v2ray.ang.util.HttpUtil
|
|||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
|
|
||||||
class UserAssetViewModel : ViewModel() {
|
class UserAssetViewModel : ViewModel() {
|
||||||
private val assets = mutableListOf<AssetUrlCache>()
|
private val assets = mutableListOf<AssetUrlCache>()
|
||||||
@@ -61,7 +59,12 @@ class UserAssetViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadGeoFiles(extDir: File, httpPort: Int): GeoDownloadResult {
|
fun downloadGeoFiles(
|
||||||
|
extDir: File,
|
||||||
|
httpPort: Int,
|
||||||
|
proxyUsername: String? = null,
|
||||||
|
proxyPassword: String? = null
|
||||||
|
): GeoDownloadResult {
|
||||||
val snapshot = getAssets()
|
val snapshot = getAssets()
|
||||||
var successCount = 0
|
var successCount = 0
|
||||||
val failures = mutableListOf<String>()
|
val failures = mutableListOf<String>()
|
||||||
@@ -69,7 +72,7 @@ class UserAssetViewModel : ViewModel() {
|
|||||||
snapshot.forEach { cache ->
|
snapshot.forEach { cache ->
|
||||||
val item = cache.assetUrl
|
val item = cache.assetUrl
|
||||||
val portsToTry = if (httpPort == 0) listOf(0) else listOf(httpPort, 0)
|
val portsToTry = if (httpPort == 0) listOf(0) else listOf(httpPort, 0)
|
||||||
if (portsToTry.any { tryDownload(item, extDir, it) }) {
|
if (portsToTry.any { tryDownload(item, extDir, it, proxyUsername, proxyPassword) }) {
|
||||||
successCount++
|
successCount++
|
||||||
} else {
|
} else {
|
||||||
failures.add(item.remarks)
|
failures.add(item.remarks)
|
||||||
@@ -79,25 +82,22 @@ class UserAssetViewModel : ViewModel() {
|
|||||||
return GeoDownloadResult(successCount, failures.size, failures)
|
return GeoDownloadResult(successCount, failures.size, failures)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryDownload(item: AssetUrlItem, extDir: File, httpPort: Int): Boolean {
|
private fun tryDownload(
|
||||||
|
item: AssetUrlItem,
|
||||||
|
extDir: File,
|
||||||
|
httpPort: Int,
|
||||||
|
proxyUsername: String? = null,
|
||||||
|
proxyPassword: String? = null
|
||||||
|
): Boolean {
|
||||||
val targetTemp = File(extDir, item.remarks + "_temp")
|
val targetTemp = File(extDir, item.remarks + "_temp")
|
||||||
val target = File(extDir, item.remarks)
|
val target = File(extDir, item.remarks)
|
||||||
val conn = HttpUtil.createProxyConnection(item.url, httpPort, 15000, 15000, needStream = true) ?: return false
|
|
||||||
try {
|
try {
|
||||||
val responseCode = conn.responseCode
|
if (HttpUtil.downloadToFile(item.url, targetTemp, 15000, httpPort, proxyUsername, proxyPassword)) {
|
||||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
|
||||||
conn.inputStream.use { inputStream ->
|
|
||||||
FileOutputStream(targetTemp).use { output ->
|
|
||||||
inputStream.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
targetTemp.renameTo(target)
|
targetTemp.renameTo(target)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
|
LogUtil.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
|
||||||
} finally {
|
|
||||||
conn.disconnect()
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/recycler_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||||
|
|
||||||
|
|
||||||
@@ -114,13 +114,34 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/routing_settings_process" />
|
android:text="@string/routing_settings_process" />
|
||||||
|
|
||||||
<EditText
|
<RelativeLayout
|
||||||
android:id="@+id/et_process"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="@string/routing_settings_tips"
|
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||||
android:inputType="textMultiLine"
|
android:layout_marginBottom="@dimen/padding_spacing_dp8">
|
||||||
android:maxLines="10" />
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/et_process"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/routing_settings_tips"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:maxLines="10"
|
||||||
|
android:minHeight="48dp"
|
||||||
|
android:paddingStart="0dp"
|
||||||
|
android:paddingEnd="48dp" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_process_picker"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/routing_settings_process"
|
||||||
|
android:src="@drawable/ic_per_apps_24dp" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/search_view"
|
||||||
|
android:icon="@drawable/ic_description_24dp"
|
||||||
|
android:title="@string/menu_item_search"
|
||||||
|
app:actionViewClass="androidx.appcompat.widget.SearchView"
|
||||||
|
app:showAsAction="ifRoom" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/select_all"
|
||||||
|
android:icon="@drawable/ic_select_all_24dp"
|
||||||
|
android:title="@string/menu_item_select_all"
|
||||||
|
app:showAsAction="withText" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/invert_selection"
|
||||||
|
android:icon="@drawable/ic_select_all_24dp"
|
||||||
|
android:title="@string/menu_item_invert_selection"
|
||||||
|
app:showAsAction="withText" />
|
||||||
|
|
||||||
|
</menu>
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
<string name="summary_pref_ipv6_enabled">Добавить IPv6-адрес и маршруты в VPN-интерфейс</string>
|
<string name="summary_pref_ipv6_enabled">Добавить IPv6-адрес и маршруты в VPN-интерфейс</string>
|
||||||
|
|
||||||
<string name="title_pref_prefer_ipv6">Предпочитать IPv6</string>
|
<string name="title_pref_prefer_ipv6">Предпочитать IPv6</string>
|
||||||
<string name="summary_pref_prefer_ipv6">Предпочитать IPv6-адреса при разрешении доменных имён</string>
|
<string name="summary_pref_prefer_ipv6">Предпочитать IPv6-адреса при определении доменных имён</string>
|
||||||
|
|
||||||
<string name="title_pref_remote_dns">Удалённая DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
|
<string name="title_pref_remote_dns">Удалённая DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
|
||||||
<string name="summary_pref_remote_dns">DNS</string>
|
<string name="summary_pref_remote_dns">DNS</string>
|
||||||
@@ -198,8 +198,10 @@
|
|||||||
<string name="title_pref_vpn_bypass_lan">VPN обходит LAN</string>
|
<string name="title_pref_vpn_bypass_lan">VPN обходит LAN</string>
|
||||||
|
|
||||||
<string name="title_pref_vpn_interface_address">Адрес интерфейса VPN</string>
|
<string name="title_pref_vpn_interface_address">Адрес интерфейса VPN</string>
|
||||||
|
|
||||||
<string name="title_pref_vpn_mtu">VPN MTU (по умолчанию 1500)</string>
|
<string name="title_pref_vpn_mtu">VPN MTU (по умолчанию 1500)</string>
|
||||||
|
|
||||||
|
|
||||||
<string name="title_pref_domestic_dns">Внутренняя DNS (необязательно)</string>
|
<string name="title_pref_domestic_dns">Внутренняя DNS (необязательно)</string>
|
||||||
<string name="summary_pref_domestic_dns">DNS</string>
|
<string name="summary_pref_domestic_dns">DNS</string>
|
||||||
|
|
||||||
@@ -221,7 +223,7 @@
|
|||||||
|
|
||||||
<string name="title_pref_socks_port">Порт локального прокси</string>
|
<string name="title_pref_socks_port">Порт локального прокси</string>
|
||||||
<string name="summary_pref_socks_port">Порт локального прокси</string>
|
<string name="summary_pref_socks_port">Порт локального прокси</string>
|
||||||
<string name="title_pref_dynamic_socks_port">Динамически менять порт</string>
|
<string name="title_pref_dynamic_socks_port">Динамически менять порт локального прокси</string>
|
||||||
<string name="summary_pref_dynamic_socks_port">Использовать случайный порт локального прокси при каждом создании соединения</string>
|
<string name="summary_pref_dynamic_socks_port">Использовать случайный порт локального прокси при каждом создании соединения</string>
|
||||||
<string name="title_pref_socks_username">Пользователь локального прокси (необязательно)</string>
|
<string name="title_pref_socks_username">Пользователь локального прокси (необязательно)</string>
|
||||||
<string name="summary_pref_socks_username">Имя пользователя</string>
|
<string name="summary_pref_socks_username">Имя пользователя</string>
|
||||||
@@ -245,6 +247,7 @@
|
|||||||
|
|
||||||
<string name="title_pref_group_all_display">Общая вкладка групп</string>
|
<string name="title_pref_group_all_display">Общая вкладка групп</string>
|
||||||
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
|
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
|
||||||
|
|
||||||
<!-- AboutActivity -->
|
<!-- AboutActivity -->
|
||||||
<string name="title_pref_feedback">Обратная связь</string>
|
<string name="title_pref_feedback">Обратная связь</string>
|
||||||
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
|
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
|
||||||
@@ -306,7 +309,7 @@
|
|||||||
<string name="title_import_config_count">Импортировано профилей: %d</string>
|
<string name="title_import_config_count">Импортировано профилей: %d</string>
|
||||||
<string name="title_export_config_count">Экспортировано профилей: %d</string>
|
<string name="title_export_config_count">Экспортировано профилей: %d</string>
|
||||||
<string name="title_update_config_count">Обновлено профилей: %d</string>
|
<string name="title_update_config_count">Обновлено профилей: %d</string>
|
||||||
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибок: %3$d, пропущено: %4$d)</string>
|
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибки: %3$d, пропущено: %4$d)</string>
|
||||||
<string name="title_update_subscription_no_subscription">Нет подписок</string>
|
<string name="title_update_subscription_no_subscription">Нет подписок</string>
|
||||||
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
|
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
|
||||||
<string name="toast_fragment_not_available">Фрагмент недоступен</string>
|
<string name="toast_fragment_not_available">Фрагмент недоступен</string>
|
||||||
@@ -318,7 +321,7 @@
|
|||||||
<!-- RoutingSettingActivity -->
|
<!-- RoutingSettingActivity -->
|
||||||
<string name="routing_settings_domain_strategy">Доменная стратегия</string>
|
<string name="routing_settings_domain_strategy">Доменная стратегия</string>
|
||||||
<string name="routing_settings_title">Маршрутизация</string>
|
<string name="routing_settings_title">Маршрутизация</string>
|
||||||
<string name="routing_settings_tips">Разделяйте запятой (,). Укажите только одно: domain, ip или process</string>
|
<string name="routing_settings_tips">Через запятую (,)\nТолько что-то одно: домен, IP или процесс</string>
|
||||||
<string name="routing_settings_save">Сохранить</string>
|
<string name="routing_settings_save">Сохранить</string>
|
||||||
<string name="routing_settings_delete">Очистить</string>
|
<string name="routing_settings_delete">Очистить</string>
|
||||||
<string name="routing_settings_rule_title">Настройка правил маршрутизации</string>
|
<string name="routing_settings_rule_title">Настройка правил маршрутизации</string>
|
||||||
@@ -331,6 +334,7 @@
|
|||||||
<string name="routing_settings_locked">Постоянное (сохранится при импорте правил)</string>
|
<string name="routing_settings_locked">Постоянное (сохранится при импорте правил)</string>
|
||||||
<string name="routing_settings_domain">Домен</string>
|
<string name="routing_settings_domain">Домен</string>
|
||||||
<string name="routing_settings_ip">IP</string>
|
<string name="routing_settings_ip">IP</string>
|
||||||
|
<string name="routing_settings_process">Процесс (название пакета — поддерживается только при использовании Xray TUN, включённой функции routeOnly и ОС Android 10+)</string>
|
||||||
<string name="routing_settings_port">Порт</string>
|
<string name="routing_settings_port">Порт</string>
|
||||||
<string name="routing_settings_protocol">Протокол</string>
|
<string name="routing_settings_protocol">Протокол</string>
|
||||||
<string name="routing_settings_protocol_tip">[http,tls,bittorrent]</string>
|
<string name="routing_settings_protocol_tip">[http,tls,bittorrent]</string>
|
||||||
@@ -353,8 +357,8 @@
|
|||||||
<string name="import_subscription_failure">Невозможно импортировать подписку</string>
|
<string name="import_subscription_failure">Невозможно импортировать подписку</string>
|
||||||
<string name="title_fragment_settings">Настройки фрагментирования</string>
|
<string name="title_fragment_settings">Настройки фрагментирования</string>
|
||||||
<string name="title_pref_fragment_packets">Фрагментирование пакетов</string>
|
<string name="title_pref_fragment_packets">Фрагментирование пакетов</string>
|
||||||
<string name="title_pref_fragment_length">Длина фрагмента (от - до)</string>
|
<string name="title_pref_fragment_length">Длина фрагмента (от — до)</string>
|
||||||
<string name="title_pref_fragment_interval">Интервал фрагментов (от - до)</string>
|
<string name="title_pref_fragment_interval">Интервал фрагментов (от — до)</string>
|
||||||
<string name="title_pref_fragment_enabled">Использовать фрагментирование</string>
|
<string name="title_pref_fragment_enabled">Использовать фрагментирование</string>
|
||||||
|
|
||||||
<string name="update_check_for_update">Проверить обновление</string>
|
<string name="update_check_for_update">Проверить обновление</string>
|
||||||
@@ -429,6 +433,7 @@
|
|||||||
<item>Определять и добавлять к узлам DNS</item>
|
<item>Определять и добавлять к узлам DNS</item>
|
||||||
<item>Определять и заменять домен</item>
|
<item>Определять и заменять домен</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string-array name="policy_group_type">
|
<string-array name="policy_group_type">
|
||||||
<item>Наименьшая задержка</item>
|
<item>Наименьшая задержка</item>
|
||||||
<item>Наименьшая нагрузка</item>
|
<item>Наименьшая нагрузка</item>
|
||||||
|
|||||||
@@ -144,6 +144,7 @@
|
|||||||
<string name="menu_item_import_proxy_app">Import from Clipboard</string>
|
<string name="menu_item_import_proxy_app">Import from Clipboard</string>
|
||||||
<string name="per_app_proxy_settings">Per-app settings</string>
|
<string name="per_app_proxy_settings">Per-app settings</string>
|
||||||
<string name="per_app_proxy_settings_enable">Enable per-app</string>
|
<string name="per_app_proxy_settings_enable">Enable per-app</string>
|
||||||
|
<string name="app_picker_unknown_app">Unknown app (unidentified UID)</string>
|
||||||
|
|
||||||
<!-- Preferences -->
|
<!-- Preferences -->
|
||||||
<string name="title_settings">Settings</string>
|
<string name="title_settings">Settings</string>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.1.0"
|
agp = "9.1.1"
|
||||||
desugarJdkLibs = "2.1.5"
|
desugarJdkLibs = "2.1.5"
|
||||||
gradleLicensePlugin = "0.9.8"
|
gradleLicensePlugin = "0.9.8"
|
||||||
kotlin = "2.3.10"
|
kotlin = "2.3.10"
|
||||||
|
|||||||
+1
-1
Submodule hev-socks5-tunnel updated: cf312ec222...dfabaa9cad
Reference in New Issue
Block a user