From 8c5375ec62006107147db5abadf1cddaacb3f8a8 Mon Sep 17 00:00:00 2001
From: 2dust <31833384+2dust@users.noreply.github.com>
Date: Sat, 18 Apr 2026 16:10:12 +0800
Subject: [PATCH] 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.
---
V2rayNG/app/src/main/AndroidManifest.xml | 3 +
.../com/v2ray/ang/ui/AppPickerActivity.kt | 179 ++++++++++++++++++
.../com/v2ray/ang/ui/AppSelectorAdapter.kt | 64 +++++++
.../com/v2ray/ang/ui/RoutingEditActivity.kt | 30 +++
.../main/res/layout/activity_app_picker.xml | 10 +
.../main/res/layout/activity_routing_edit.xml | 31 ++-
.../app/src/main/res/menu/menu_app_picker.xml | 25 +++
7 files changed, 337 insertions(+), 5 deletions(-)
create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppPickerActivity.kt
create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelectorAdapter.kt
create mode 100644 V2rayNG/app/src/main/res/layout/activity_app_picker.xml
create mode 100644 V2rayNG/app/src/main/res/menu/menu_app_picker.xml
diff --git a/V2rayNG/app/src/main/AndroidManifest.xml b/V2rayNG/app/src/main/AndroidManifest.xml
index 03825143..9d2dd6e8 100644
--- a/V2rayNG/app/src/main/AndroidManifest.xml
+++ b/V2rayNG/app/src/main/AndroidManifest.xml
@@ -88,6 +88,9 @@
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppPickerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppPickerActivity.kt
new file mode 100644
index 00000000..c3234b31
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppPickerActivity.kt
@@ -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 = 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 {
+ 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()
+ private var appsAll: List = 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): List {
+ 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) {
+ 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 {
+ return ArrayList(selectedPackages.sorted())
+ }
+
+ private fun resolveScreenTitle(): String {
+ return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
+ }
+}
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelectorAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelectorAdapter.kt
new file mode 100644
index 00000000..719664a4
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelectorAdapter.kt
@@ -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
+) : RecyclerView.Adapter() {
+
+ var apps: List = 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) {
+ 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)
+ }
+ }
+}
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt
index f20ce504..27a6646a 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt
@@ -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 {
+ 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.
diff --git a/V2rayNG/app/src/main/res/layout/activity_app_picker.xml b/V2rayNG/app/src/main/res/layout/activity_app_picker.xml
new file mode 100644
index 00000000..55444199
--- /dev/null
+++ b/V2rayNG/app/src/main/res/layout/activity_app_picker.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml b/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml
index d5ad6052..9db3ec8e 100644
--- a/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml
+++ b/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml
@@ -114,13 +114,34 @@
android:layout_height="wrap_content"
android:text="@string/routing_settings_process" />
-
+ android:layout_marginTop="@dimen/padding_spacing_dp8"
+ android:layout_marginBottom="@dimen/padding_spacing_dp8">
+
+
+
+
+
+
+
+