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.
This commit is contained in:
1 parent
30792299e8
commit
8c5375ec62
7 files changed
+337
-5
No files matched your search
@@ -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" />
|
||||||
|
|||||||
@@ -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.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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
Reference in new issue
Block a user