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 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 }}
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang" applicationId = "com.v2ray.ang"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 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(';')
+3
View File
@@ -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>
+11 -6
View File
@@ -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 -1
View File
@@ -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"