From 17d03bcc46af0cfe49c579db129e8a36d3cb9681 Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Thu, 6 Aug 2026 12:04:39 +0300 Subject: [PATCH] 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> --- .../java/com/v2ray/ang/ui/AppSelection.kt | 50 +++++ .../ang/ui/apppicker/AppPickerActivity.kt | 4 +- .../ang/ui/apppicker/AppPickerViewModel.kt | 19 +- .../ang/ui/perappproxy/PerAppProxyActivity.kt | 4 +- .../ui/perappproxy/PerAppProxyViewModel.kt | 193 +++++++++--------- 5 files changed, 161 insertions(+), 109 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelection.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelection.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelection.kt new file mode 100644 index 000000000..2798951a6 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelection.kt @@ -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, packageNames: Collection): Set { + 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, + proxyAppList: String, + bypassApps: Boolean, + forceGoogleApps: Boolean + ): Set { + 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) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerActivity.kt index 41afd3a9f..7cc29048d 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerActivity.kt @@ -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), diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerViewModel.kt index c04c83627..9df05f0ec 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/apppicker/AppPickerViewModel.kt @@ -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 = _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) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/perappproxy/PerAppProxyActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/perappproxy/PerAppProxyActivity.kt index 839f83dbb..ba1b0d38b 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/perappproxy/PerAppProxyActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/perappproxy/PerAppProxyActivity.kt @@ -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), diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/perappproxy/PerAppProxyViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/perappproxy/PerAppProxyViewModel.kt index 7c0f37ef8..388b8bdb9 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/perappproxy/PerAppProxyViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/perappproxy/PerAppProxyViewModel.kt @@ -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? = 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) { - val newSet = _blacklist.value + packages - if (newSet != _blacklist.value) { - _blacklist.value = newSet - saveBlacklist() - } - } - - fun removeAll(packages: Collection) { - 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 { 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) { + 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 { + 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): List { 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() } -} \ No newline at end of file +}