Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1824c7598 | |||
| 18eebf999d | |||
| e37d6b7523 | |||
| 8c5375ec62 |
+1
-1
Submodule AndroidLibXrayLite updated: 203f317afd...03a47ef527
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 720
|
||||
versionName = "2.1.0"
|
||||
versionCode = 721
|
||||
versionName = "2.1.1"
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
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.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 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)
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
|
||||
sortApps(appsList)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user