Optimize app picker filtering and bulk selection (#6000)

* Optimize app picker filtering and selection

Avoid per-query string copies and repeated state persistence or service restart requests during bulk selection. Preserve the active filter while apps load, calculate automatic and imported selections away from the main thread, and keep upstream's asynchronous Coil icon pipeline unchanged.

* Remove enabled: Boolean = true

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
This commit is contained in:
eliotcougarand2dust authored and GitHub committed 2026-08-06 17:04:39 +08:00
1 parent efb16e7979
commit 17d03bcc46
5 files changed
+161 -109

No files matched your search

@@ -0,0 +1,50 @@
package com.v2ray.ang.ui
/**
* Pure selection operations shared by application-list screens.
*
* Keeping the calculations separate lets bulk actions build one result before it is published.
*/
internal object AppSelection {
fun invert(currentSelection: Set<String>, packageNames: Collection<String>): Set<String> {
return currentSelection.toMutableSet().apply {
packageNames.forEach { packageName ->
if (!add(packageName)) {
remove(packageName)
}
}
}
}
/**
* Builds the selected package set while preserving the legacy list matching behavior.
*
* The full list text is searched for each installed package name. This is intentionally not
* exact line matching; changing that behavior should be handled separately.
*/
fun fromProxyList(
packageNames: Collection<String>,
proxyAppList: String,
bypassApps: Boolean,
forceGoogleApps: Boolean
): Set<String> {
return buildSet(packageNames.size) {
packageNames.forEach { packageName ->
val shouldProxy = shouldProxy(packageName, proxyAppList, forceGoogleApps)
val shouldSelect = if (bypassApps) !shouldProxy else shouldProxy
if (shouldSelect) {
add(packageName)
}
}
}
}
private fun shouldProxy(packageName: String, proxyAppList: String, forceGoogleApps: Boolean): Boolean {
if (forceGoogleApps) {
if (packageName == "com.google.android.webview") return false
if (packageName.startsWith("com.google")) return true
}
return proxyAppList.contains(packageName)
}
}
@@ -123,7 +123,7 @@ fun AppPickerScreen(
var showMenu by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
LaunchedEffect(searchQuery) {
LaunchedEffect(Unit) {
onSearch(searchQuery)
}
@@ -138,9 +138,11 @@ fun AppPickerScreen(
searchQuery = searchQuery,
onSearchQueryChange = { query ->
searchQuery = query
onSearch(query)
},
onSearchClose = {
searchQuery = ""
onSearch("")
showSearch = false
},
searchPlaceholder = stringResource(R.string.menu_item_search),
@@ -5,6 +5,7 @@ import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.ui.AppSelection
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.LogUtil
@@ -80,15 +81,17 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
}
fun selectAll() {
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
_selectedPackages.value = _selectedPackages.value + displayedPackages
val displayedApps = _displayedApps.value
val currentSelection = _selectedPackages.value
_selectedPackages.value = buildSet(currentSelection.size + displayedApps.size) {
addAll(currentSelection)
displayedApps.forEach { add(it.packageName) }
}
}
fun invertSelection() {
val current = _selectedPackages.value
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
_selectedPackages.value = (current - displayedPackages) +
(displayedPackages - current)
val packageNames = _displayedApps.value.map { it.packageName }
_selectedPackages.value = AppSelection.invert(_selectedPackages.value, packageNames)
}
fun getSelectedPackages(): List<String> = _selectedPackages.value.sorted()
@@ -97,9 +100,9 @@ class AppPickerViewModel(application: Application) : BaseViewModel(application)
val apps = allApps ?: return emptyList()
if (query.isBlank()) return apps
val key = query.uppercase()
return apps.filter {
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
it.appName.contains(query, ignoreCase = true) ||
it.packageName.contains(query, ignoreCase = true)
}
}
@@ -132,7 +132,7 @@ fun PerAppProxyScreen(
var showMenu by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
LaunchedEffect(searchQuery) {
LaunchedEffect(Unit) {
onSearch(searchQuery)
}
@@ -147,9 +147,11 @@ fun PerAppProxyScreen(
searchQuery = searchQuery,
onSearchQueryChange = { query ->
searchQuery = query
onSearch(query)
},
onSearchClose = {
searchQuery = ""
onSearch("")
showSearch = false
},
searchPlaceholder = stringResource(R.string.menu_item_search),
@@ -8,11 +8,13 @@ import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.AppSelection
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -46,64 +48,30 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
// Cached full list for filtering
private var appsAll: List<AppInfo>? = null
private var currentQuery = ""
private var isAppListLoading = false
// Blacklist operations
fun contains(packageName: String): Boolean = _blacklist.value.contains(packageName)
fun add(packageName: String) {
val newSet = _blacklist.value + packageName
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun remove(packageName: String) {
val newSet = _blacklist.value - packageName
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun toggle(packageName: String) {
if (_blacklist.value.contains(packageName)) {
remove(packageName)
val currentSelection = _blacklist.value
val newSelection = if (packageName in currentSelection) {
currentSelection - packageName
} else {
add(packageName)
}
}
fun addAll(packages: Collection<String>) {
val newSet = _blacklist.value + packages
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun removeAll(packages: Collection<String>) {
val newSet = _blacklist.value - packages.toSet()
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun clear() {
if (_blacklist.value.isNotEmpty()) {
_blacklist.value = emptySet()
saveBlacklist()
currentSelection + packageName
}
replaceBlacklist(newSelection)
SettingsChangeManager.makeRestartService()
}
private fun loadBlacklist(): Set<String> {
return MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.toSet() ?: emptySet()
}
private fun saveBlacklist() {
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, _blacklist.value.toMutableSet())
SettingsChangeManager.makeRestartService()
private fun replaceBlacklist(newBlacklist: Set<String>) {
if (newBlacklist == _blacklist.value) return
_blacklist.value = newBlacklist
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, newBlacklist.toMutableSet())
}
// Per‑app proxy switch
@@ -123,36 +91,49 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
// Load and filter apps
fun loadApps(context: Context) {
if (appsAll != null || isAppListLoading) return
val applicationContext = context.applicationContext
isAppListLoading = true
launchLoading {
try {
val apps = withContext(Dispatchers.IO) {
val list = AppManagerUtil.loadNetworkAppList(context)
val list = AppManagerUtil.loadNetworkAppList(applicationContext)
sortApps(list)
}
appsAll = apps
_displayedApps.value = apps
_displayedApps.value = applyFilter(currentQuery)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.ANG_PACKAGE, "Error loading apps", e)
} finally {
isAppListLoading = false
}
}
}
fun filterApps(query: String) {
val key = query.uppercase()
_displayedApps.value = if (key.isNotEmpty()) {
appsAll?.filter {
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
} ?: emptyList()
} else {
appsAll ?: emptyList()
currentQuery = query
_displayedApps.value = applyFilter(query)
}
private fun applyFilter(query: String): List<AppInfo> {
val apps = appsAll ?: return emptyList()
if (query.isEmpty()) return apps
return apps.filter {
it.appName.contains(query, ignoreCase = true) ||
it.packageName.contains(query, ignoreCase = true)
}
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
val selectedPackages = _blacklist.value
return apps.sortedWith { p1, p2 ->
val s1 = contains(p1.packageName)
val s2 = contains(p2.packageName)
val s1 = p1.packageName in selectedPackages
val s2 = p2.packageName in selectedPackages
when {
s1 && !s2 -> -1
!s1 && s2 -> 1
@@ -165,19 +146,26 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
// Bulk actions
fun selectAll() {
val pkgNames = _displayedApps.value.map { it.packageName }
val allSelected = pkgNames.all { contains(it) }
if (allSelected) removeAll(pkgNames) else addAll(pkgNames)
// Enable per‑app proxy after selection
val displayedApps = _displayedApps.value
val currentSelection = _blacklist.value
val allSelected = displayedApps.all { it.packageName in currentSelection }
val newSelection = currentSelection.toMutableSet().apply {
displayedApps.forEach { app ->
if (allSelected) remove(app.packageName) else add(app.packageName)
}
}
replaceBlacklist(newSelection)
enablePerAppProxyAndRestart()
}
fun invertSelection() {
_displayedApps.value.forEach { toggle(it.packageName) }
val packageNames = _displayedApps.value.map { it.packageName }
replaceBlacklist(AppSelection.invert(_blacklist.value, packageNames))
enablePerAppProxyAndRestart()
}
fun selectProxyAppAuto(context: Context) {
val applicationContext = context.applicationContext
launchLoading {
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
var content = withContext(Dispatchers.IO) {
@@ -204,7 +192,11 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
)
} ?: ""
}
val success = selectProxyApp(content, true, context)
val success = applyProxyAppList(
content = content,
context = applicationContext,
forceGoogleApps = true
)
if (success) {
enablePerAppProxyAndRestart()
}
@@ -213,42 +205,53 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
fun importProxyApp(content: String?, context: Context) {
if (content.isNullOrEmpty()) return
val success = selectProxyApp(content, false, context)
if (success) {
enablePerAppProxyAndRestart()
val applicationContext = context.applicationContext
launchLoading {
val success = applyProxyAppList(
content = content,
context = applicationContext,
forceGoogleApps = false
)
if (success) {
enablePerAppProxyAndRestart()
}
}
}
fun exportProxyApp(): String {
var result = _bypassApps.value.toString()
_blacklist.value.forEach { pkg ->
result = result + System.lineSeparator() + pkg
return buildString {
append(_bypassApps.value)
_blacklist.value.forEach { packageName ->
append(System.lineSeparator())
append(packageName)
}
}
return result
}
private fun selectProxyApp(content: String, force: Boolean, context: Context): Boolean {
try {
val proxyApps = if (content.isEmpty()) {
Utils.readTextFromAssets(context, "proxy_package_name")
} else content
if (proxyApps.isNullOrEmpty()) return false
private suspend fun applyProxyAppList(content: String, context: Context, forceGoogleApps: Boolean): Boolean {
val installedApps = appsAll ?: return false
clear()
val apps = _displayedApps.value
if (_bypassApps.value) {
apps.forEach { app ->
if (!inProxyApps(proxyApps, app.packageName, force)) {
add(app.packageName)
}
}
} else {
apps.forEach { app ->
if (inProxyApps(proxyApps, app.packageName, force)) {
add(app.packageName)
}
try {
val proxyAppList = if (content.isEmpty()) {
withContext(Dispatchers.IO) {
Utils.readTextFromAssets(context, "proxy_package_name")
}
} else content
if (proxyAppList.isNullOrEmpty()) return false
val bypassApps = _bypassApps.value
val newBlacklist = withContext(Dispatchers.Default) {
AppSelection.fromProxyList(
packageNames = installedApps.map { it.packageName },
proxyAppList = proxyAppList,
bypassApps = bypassApps,
forceGoogleApps = forceGoogleApps
)
}
replaceBlacklist(newBlacklist)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
return false
@@ -256,16 +259,8 @@ class PerAppProxyViewModel(application: Application) : BaseViewModel(application
return true
}
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
if (force) {
if (packageName == "com.google.android.webview") return false
if (packageName.startsWith("com.google")) return true
}
return proxyApps.indexOf(packageName) >= 0
}
private fun enablePerAppProxyAndRestart() {
setPerAppProxyEnabled(true)
SettingsChangeManager.makeRestartService()
}
}
}