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