Compare commits

..

12 Commits

Author SHA1 Message Date
2dust 659f2ded09 up 2.1.2 2026-04-24 10:52:42 +08:00
2dust 585d64b0be Bump Android Gradle Plugin to 9.1.1
Update agp from 9.1.0 to 9.1.1 in V2rayNG/gradle/libs.versions.toml to pick up the latest patch fixes and keep the build tooling current.
2026-04-24 10:52:02 +08:00
fanymagnet 2ae2e747e2 Replace package names on UIDs in custom config (#5527)
* Replace package names on UIDs in custom config

* Update V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix copilot review & detect tun optimization

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-24 10:39:19 +08:00
2dust 4199cee4e5 Use OkHttp with proxy auth and support SOCKS creds
Replace HttpURLConnection-based networking with OkHttp and add proxy username/password support. Introduces buildOkHttpClient, updated getUrlContent/getUrlContentWithUserAgent signatures, and downloadToFile; callers in AngConfigManager, SpeedtestManager, UpdateCheckerManager, PerAppProxyActivity, UserAssetActivity and UserAssetViewModel now pass SOCKS proxy credentials. Removes createProxyConnection and related URL-resolve logic, clears HTTP inbound auth/udp in V2rayConfigManager, and simplifies SettingsManager port generation. These changes enable authenticated proxy requests, improved redirect handling and more robust file downloads.
2026-04-23 20:40:17 +08:00
dependabot[bot] 8be8a912c4 Bump robinraju/release-downloader from 1.12 to 1.13 (#5520)
Bumps [robinraju/release-downloader](https://github.com/robinraju/release-downloader) from 1.12 to 1.13.
- [Release notes](https://github.com/robinraju/release-downloader/releases)
- [Commits](https://github.com/robinraju/release-downloader/compare/v1.12...v1.13)

---
updated-dependencies:
- dependency-name: robinraju/release-downloader
  dependency-version: '1.13'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:44:50 +08:00
Kapkap5454 851f22680a Add negative UID handling to process routing (#5509)
* Handle unidentified package UID resolution

Add special handling for unidentified package names.

* Add method to create special unidentified app item

Added a method to create a special unidentified app item for the app picker.

* Support unidentified app UID and label

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-19 19:48:59 +08:00
2dust 12abc99bfc Update hev-socks5-tunnel 2026-04-19 19:31:11 +08:00
2dust 07e50637a2 Update AndroidLibXrayLite 2026-04-19 19:30:40 +08:00
2dust c1824c7598 up 2.1.1 2026-04-18 16:15:32 +08:00
2dust 18eebf999d Update AndroidLibXrayLite 2026-04-18 16:15:02 +08:00
solokot e37d6b7523 Update & improve Russian translation (#5505) 2026-04-18 16:10:49 +08:00
2dust 8c5375ec62 Add app picker and process picker integration
Introduce an AppPickerActivity to browse and select installed apps (with search, select-all and invert selection). Add AppSelectorAdapter, activity_app_picker layout and menu_app_picker menu, and register the activity in AndroidManifest.xml. Integrate the picker into RoutingEditActivity using an ActivityResult launcher and add a btn_process_picker to the routing edit layout; selected package list is loaded into the process EditText. Minor UI adjustments include padding for the EditText and marking system apps in the list.
2026-04-18 16:10:12 +08:00
25 changed files with 587 additions and 233 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
popd
- name: Download libv2ray
uses: robinraju/release-downloader@v1.12
uses: robinraju/release-downloader@v1.13
with:
repository: '2dust/AndroidLibXrayLite'
tag: ${{ env.CURRENT_TAG }}
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 36
versionCode = 720
versionName = "2.1.0"
versionCode = 722
versionName = "2.1.2"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
+3
View File
@@ -88,6 +88,9 @@
<activity
android:name=".ui.PerAppProxyActivity"
android:exported="false" />
<activity
android:name=".ui.AppPickerActivity"
android:exported="false" />
<activity
android:name=".ui.ScannerActivity"
android:exported="false" />
@@ -219,6 +219,8 @@ object AppConfig {
const val REALITY = "reality"
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_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")
@@ -532,10 +532,12 @@ object AngConfigManager {
}
LogUtil.i(AppConfig.TAG, url)
val userAgent = it.subscription.userAgent
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort, proxyUsername, proxyPassword)
} catch (e: Exception) {
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
@@ -317,11 +317,7 @@ object SettingsManager {
}
private fun generateRandomSocksPort(): Int {
return if (Utils.isXray()) {
Random.nextInt(10000, 65536)
} else {
Random.nextInt(10000, 65535)
}
return 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? {
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
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 ip = listOf(
@@ -23,10 +23,13 @@ object UpdateCheckerManager {
AppConfig.APP_API_URL.concatUrl("latest")
}
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var response = HttpUtil.getUrlContent(url, 5000)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort)
response = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword)
?: 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 {
val v1 = version1.split(".")
val v2 = version2.split(".")
@@ -98,36 +98,46 @@ object V2rayConfigManager {
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()
}
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
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
// Check whether package names need to be replaced with UIDs
if (SettingsManager.canUseProcessRouting()) {
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
?: JsonArray()
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
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)
// check if tun inbound exists
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
?: JsonArray().also { json.add("inbounds", it) }
val tunNotExists = inboundsJson.none { elem ->
elem.isJsonObject && elem.asJsonObject.get("protocol")
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
?.asString == "tun"
}
val updatedRaw = JsonUtil.toJsonPretty(json) ?: return result
return ConfigResult(true, guid, updatedRaw)
if (tunNotExists) {
// 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.port = SettingsManager.getHttpPort()
inbound2.protocol = EConfigType.HTTP.name.lowercase()
inbound2.settings?.auth = null
inbound2.settings?.udp = null
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) {
var content = HttpUtil.getUrlContent(url, 5000)
if (content.isNullOrEmpty()) {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: ""
}
launch(Dispatchers.Main) {
//LogUtil.i(AppConfig.TAG, content)
@@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
@@ -23,12 +24,19 @@ import kotlinx.coroutines.launch
class RoutingEditActivity : BaseActivity() {
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
private val position by lazy { intent.getIntExtra("position", -1) }
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
setupOutboundTagInput()
setupProcessPicker()
val rulesetItem = SettingsManager.getRoutingRuleset(position)
if (rulesetItem != null) {
@@ -39,9 +47,31 @@ class RoutingEditActivity : BaseActivity() {
SettingsManager.canUseProcessRouting().let { canUse ->
binding.etProcess.isEnabled = canUse
binding.btnProcessPicker.isEnabled = canUse
}
}
private fun setupProcessPicker() {
binding.btnProcessPicker.setOnClickListener {
processPickerLauncher.launch(
AppPickerActivity.createIntent(
context = this,
selectedPackages = getSelectedProcessPackages(),
title = getString(R.string.routing_settings_process)
)
)
}
}
private fun getSelectedProcessPackages(): List<String> {
return binding.etProcess.text
.toString()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
}
/**
* Sets up the AutoCompleteTextView for outbound tag:
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
@@ -173,9 +173,11 @@ class UserAssetActivity : HelperBaseActivity() {
showLoading()
toast(R.string.msg_downloading_content)
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
lifecycleScope.launch(Dispatchers.IO) {
val result = viewModel.downloadGeoFiles(extDir, httpPort)
val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
withContext(Dispatchers.Main) {
if (result.successCount > 0) {
toast(getString(R.string.title_update_config_count, result.successCount))
@@ -3,10 +3,8 @@ package com.v2ray.ang.util
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.util.Utils.encode
import com.v2ray.ang.util.Utils.urlDecode
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.IDN
import java.net.Inet6Address
import java.net.InetAddress
@@ -15,6 +13,10 @@ import java.net.MalformedURLException
import java.net.Proxy
import java.net.URI
import java.net.URL
import java.util.concurrent.TimeUnit
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
object HttpUtil {
@@ -106,13 +108,31 @@ object HttpUtil {
* @param httpPort The HTTP port to use.
* @return The content of the URL as a string.
*/
fun getUrlContent(url: String, timeout: Int, httpPort: Int = 0): String? {
val conn = createProxyConnection(url, httpPort, timeout, timeout) ?: return null
fun getUrlContent(
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 {
return conn.inputStream.bufferedReader().readText()
} catch (_: Exception) {
} finally {
conn.disconnect()
client.newCall(requestBuilder.build()).execute().use { response ->
if (!response.isSuccessful) {
LogUtil.w(AppConfig.TAG, "Failed to get URL content, code=${response.code}")
return null
}
return response.body?.string()
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get URL content", e)
}
return null
}
@@ -127,127 +147,147 @@ object HttpUtil {
* @throws IOException If an I/O error occurs.
*/
@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 redirects = 0
val maxRedirects = 3
while (redirects++ < maxRedirects) {
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()) {
"v2rayNG/${BuildConfig.VERSION_NAME}"
} else {
userAgent
}
conn.setRequestProperty("User-agent", finalUserAgent)
conn.connect()
val requestBuilder = Request.Builder()
.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
when (responseCode) {
in 300..399 -> {
val location = resolveLocation(conn)
conn.disconnect()
if (location.isNullOrEmpty()) {
throw IOException("Redirect location not found")
client.newCall(requestBuilder.build()).execute().use { response ->
when {
response.isRedirect -> {
val location = response.header("Location")
if (location.isNullOrEmpty()) {
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 {
return conn.inputStream.use { it.bufferedReader().readText() }
} finally {
conn.disconnect()
response.isSuccessful -> {
return response.body?.string() ?: ""
}
else -> {
throw IOException("Request failed with status code ${response.code}")
}
}
}
}
throw IOException("Too many redirects")
}
/**
* Creates an HttpURLConnection object connected through a proxy.
*
* @param urlStr The target URL address.
* @param port The port of the proxy server.
* @param connectTimeout The connection timeout in milliseconds (default is 15000 ms).
* @param readTimeout The read timeout in milliseconds (default is 15000 ms).
* @param needStream Whether the connection needs to support streaming.
* @return Returns a configured HttpURLConnection object, or null if it fails.
*/
fun createProxyConnection(
urlStr: String,
port: Int,
connectTimeout: Int = 15000,
readTimeout: Int = 15000,
needStream: Boolean = false
): HttpURLConnection? {
private fun buildOkHttpClient(
timeout: Int,
httpPort: Int,
proxyUsername: String?,
proxyPassword: String?,
followRedirects: Boolean
): OkHttpClient {
val builder = OkHttpClient.Builder()
.connectTimeout(timeout.toLong(), TimeUnit.MILLISECONDS)
.readTimeout(timeout.toLong(), TimeUnit.MILLISECONDS)
.followRedirects(followRedirects)
.followSslRedirects(followRedirects)
var conn: HttpURLConnection? = null
try {
val url = URL(urlStr)
// Create a connection
conn = if (port == 0) {
url.openConnection()
} else {
url.openConnection(
Proxy(
Proxy.Type.HTTP,
InetSocketAddress(LOOPBACK, port)
)
)
} as HttpURLConnection
// Set connection and read timeouts
conn.connectTimeout = connectTimeout
conn.readTimeout = readTimeout
if (!needStream) {
// Set request headers
conn.setRequestProperty("Connection", "close")
// Disable automatic redirects
conn.instanceFollowRedirects = false
// Disable caching
conn.useCaches = false
if (httpPort != 0) {
builder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(LOOPBACK, httpPort)))
if (!proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
builder.proxyAuthenticator { _, response ->
if (response.request.header("Proxy-Authorization") != null) {
null
} else {
response.request.newBuilder()
.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
.build()
}
}
}
//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
fun resolveLocation(conn: HttpURLConnection): String? {
val raw = conn.getHeaderField("Location")?.trim()?.takeIf { it.isNotEmpty() } ?: return null
// Try check url is relative or absolute
private fun resolveLocation(baseUrl: String, raw: String): String? {
return try {
val locUri = URI(raw)
val baseUri = conn.url.toURI()
val baseUri = URI(baseUrl)
val resolved = if (locUri.isAbsolute) locUri else baseUri.resolve(locUri)
resolved.toURL().toString()
} catch (_: Exception) {
// Fallback: url resolver, also should handles //host/...
try {
URL(raw).toString() // absolute with protocol
URL(raw).toString()
} catch (_: MalformedURLException) {
try {
URL(conn.url, raw).toString()
URL(URL(baseUrl), raw).toString()
} catch (_: MalformedURLException) {
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.pm.PackageManager
import com.v2ray.ang.AppConfig
import java.util.concurrent.ConcurrentHashMap
object PackageUidResolver {
private const val TAG = "PackageUidResolver"
// In-process cache to avoid resolving the same package UID repeatedly.
private val packageUidCache = ConcurrentHashMap<String, String>()
@@ -32,13 +31,20 @@ object PackageUidResolver {
}
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 {
val uid = context.packageManager.getPackageUid(packageName, 0).toString()
LogUtil.d(TAG, "Package: $packageName -> UID: $uid")
LogUtil.d(AppConfig.TAG, "Package: $packageName -> UID: $uid")
uid
} catch (_: PackageManager.NameNotFoundException) {
LogUtil.w(TAG, "Package not found: $packageName")
LogUtil.w(AppConfig.TAG, "Package not found: $packageName")
null
}
}
}
}
@@ -10,8 +10,6 @@ import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
class UserAssetViewModel : ViewModel() {
private val assets = mutableListOf<AssetUrlCache>()
@@ -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()
var successCount = 0
val failures = mutableListOf<String>()
@@ -69,7 +72,7 @@ class UserAssetViewModel : ViewModel() {
snapshot.forEach { cache ->
val item = cache.assetUrl
val portsToTry = if (httpPort == 0) listOf(0) else listOf(httpPort, 0)
if (portsToTry.any { tryDownload(item, extDir, it) }) {
if (portsToTry.any { tryDownload(item, extDir, it, proxyUsername, proxyPassword) }) {
successCount++
} else {
failures.add(item.remarks)
@@ -79,25 +82,22 @@ class UserAssetViewModel : ViewModel() {
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 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)
}
}
if (HttpUtil.downloadToFile(item.url, targetTemp, 15000, httpPort, proxyUsername, proxyPassword)) {
targetTemp.renameTo(target)
return true
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
} finally {
conn.disconnect()
}
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:text="@string/routing_settings_process" />
<EditText
android:id="@+id/et_process"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/routing_settings_tips"
android:inputType="textMultiLine"
android:maxLines="10" />
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8">
<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
@@ -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>
+11 -6
View File
@@ -189,7 +189,7 @@
<string name="summary_pref_ipv6_enabled">Добавить IPv6-адрес и маршруты в VPN-интерфейс</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="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_interface_address">Адрес интерфейса VPN</string>
<string name="title_pref_vpn_mtu">VPN MTU (по умолчанию 1500)</string>
<string name="title_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="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="title_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="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string>
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
@@ -306,7 +309,7 @@
<string name="title_import_config_count">Импортировано профилей: %d</string>
<string name="title_export_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="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
<string name="toast_fragment_not_available">Фрагмент недоступен</string>
@@ -318,7 +321,7 @@
<!-- RoutingSettingActivity -->
<string name="routing_settings_domain_strategy">Доменная стратегия</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_delete">Очистить</string>
<string name="routing_settings_rule_title">Настройка правил маршрутизации</string>
@@ -331,6 +334,7 @@
<string name="routing_settings_locked">Постоянное (сохранится при импорте правил)</string>
<string name="routing_settings_domain">Домен</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_protocol">Протокол</string>
<string name="routing_settings_protocol_tip">[http,tls,bittorrent]</string>
@@ -353,8 +357,8 @@
<string name="import_subscription_failure">Невозможно импортировать подписку</string>
<string name="title_fragment_settings">Настройки фрагментирования</string>
<string name="title_pref_fragment_packets">Фрагментирование пакетов</string>
<string name="title_pref_fragment_length">Длина фрагмента (от - до)</string>
<string name="title_pref_fragment_interval">Интервал фрагментов (от - до)</string>
<string name="title_pref_fragment_length">Длина фрагмента (от — до)</string>
<string name="title_pref_fragment_interval">Интервал фрагментов (от — до)</string>
<string name="title_pref_fragment_enabled">Использовать фрагментирование</string>
<string name="update_check_for_update">Проверить обновление</string>
@@ -429,6 +433,7 @@
<item>Определять и добавлять к узлам DNS</item>
<item>Определять и заменять домен</item>
</string-array>
<string-array name="policy_group_type">
<item>Наименьшая задержка</item>
<item>Наименьшая нагрузка</item>
@@ -144,6 +144,7 @@
<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_enable">Enable per-app</string>
<string name="app_picker_unknown_app">Unknown app (unidentified UID)</string>
<!-- Preferences -->
<string name="title_settings">Settings</string>
+1 -1
View File
@@ -1,5 +1,5 @@
[versions]
agp = "9.1.0"
agp = "9.1.1"
desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8"
kotlin = "2.3.10"