mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-11-03 19:54:30 +00:00
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:
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user