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"> + + + + + + + + + + + + + + + +