From 80c74e87b67ff0ebe30d09556a3972b834eda1e5 Mon Sep 17 00:00:00 2001 From: supfeer Date: Mon, 8 Jun 2026 12:43:07 +0300 Subject: [PATCH] android: optimize split-tunnel app picker Updates tailscale/tailscale#19901 Signed-off-by: supfeer --- .../ipn/ui/util/InstalledAppsManager.kt | 46 +++- .../ipn/ui/util/SplitTunnelAppOrdering.kt | 35 +++ .../ipn/ui/view/SplitTunnelAppPickerView.kt | 137 +++++++--- .../SplitTunnelAppPickerViewModel.kt | 46 +++- .../ipn/ui/util/SplitTunnelAppOrderingTest.kt | 247 ++++++++++++++++++ 5 files changed, 464 insertions(+), 47 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/SplitTunnelAppOrdering.kt create mode 100644 android/src/test/kotlin/com/tailscale/ipn/ui/util/SplitTunnelAppOrderingTest.kt diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt b/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt index f1bea2bbb5..61f5b251ef 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt @@ -6,24 +6,56 @@ package com.tailscale.ipn.ui.util import android.Manifest import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.util.LruCache +import androidx.core.graphics.drawable.toBitmap import com.tailscale.ipn.BuildConfig +import java.text.Collator data class InstalledApp(val name: String, val packageName: String) class InstalledAppsManager( val packageManager: PackageManager, ) { + private val iconCache = + object : LruCache(4 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int { + return value.allocationByteCount / 1024 + } + } + fun fetchInstalledApps(): List { + val collator = Collator.getInstance() return packageManager - .getInstalledApplications(PackageManager.GET_META_DATA) + .getInstalledApplications(0) .filter(appIsIncluded) - .map { - InstalledApp( - name = it.loadLabel(packageManager).toString(), - packageName = it.packageName, - ) + .mapNotNull { app -> + runCatching { + InstalledApp( + name = app.loadLabel(packageManager).toString(), + packageName = app.packageName, + ) + } + .getOrNull() + } + .sortedWith { left, right -> + val nameComparison = collator.compare(left.name, right.name) + if (nameComparison != 0) nameComparison else left.packageName.compareTo(right.packageName) } - .sortedBy { it.name } + } + + fun iconForPackage(packageName: String, sizePx: Int): Bitmap { + val cacheKey = "$packageName:$sizePx" + iconCache.get(cacheKey)?.let { + return it + } + + val icon = + runCatching { packageManager.getApplicationIcon(packageName) } + .getOrElse { packageManager.defaultActivityIcon } + .toBitmap(width = sizePx, height = sizePx, config = Bitmap.Config.ARGB_8888) + iconCache.put(cacheKey, icon) + return icon } private val appIsIncluded: (ApplicationInfo) -> Boolean = { app -> diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/SplitTunnelAppOrdering.kt b/android/src/main/java/com/tailscale/ipn/ui/util/SplitTunnelAppOrdering.kt new file mode 100644 index 0000000000..df299ba334 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/SplitTunnelAppOrdering.kt @@ -0,0 +1,35 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +fun orderedSplitTunnelApps( + apps: List, + selectedPackageNames: Set, + query: String, +): List { + val filteredApps = + if (query.isBlank()) { + apps + } else { + val normalizedQuery = query.trim() + apps.filter { app -> + app.name.contains(normalizedQuery, ignoreCase = true) || + app.packageName.contains(normalizedQuery, ignoreCase = true) + } + } + + val (selectedApps, unselectedApps) = + filteredApps.partition { selectedPackageNames.contains(it.packageName) } + return selectedApps.englishNamesFirst() + unselectedApps.englishNamesFirst() +} + +private fun List.englishNamesFirst(): List { + val (englishApps, localizedApps) = partition { it.name.startsWithLatinLetter() } + return englishApps + localizedApps +} + +private fun String.startsWithLatinLetter(): Boolean { + val firstLetter = firstOrNull { it.isLetter() } ?: return false + return Character.UnicodeScript.of(firstLetter.code) == Character.UnicodeScript.LATIN +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt index 5994532ed0..5aaef0c583 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -6,15 +6,21 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator @@ -23,19 +29,22 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.App import com.tailscale.ipn.R @@ -49,13 +58,20 @@ fun SplitTunnelAppPickerView( model: SplitTunnelAppPickerViewModel = viewModel(), ) { val installedApps by model.installedApps.collectAsState() + val displayedApps by model.displayedApps.collectAsState() val selectedPackageNames by model.selectedPackageNames.collectAsState() + val searchQuery by model.searchQuery.collectAsState() + val appIcons by model.appIcons.collectAsState() val allowSelected by model.allowSelected.collectAsState() val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState() val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState() val showHeaderMenu by model.showHeaderMenu.collectAsState() val showSwitchDialog by model.showSwitchDialog.collectAsState() + val appIconSize = 40.dp + val appIconSizePx = with(LocalDensity.current) { appIconSize.roundToPx() } + + LaunchedEffect(installedApps, appIconSizePx) { model.preloadIcons(installedApps, appIconSizePx) } if (showSwitchDialog) { SwitchAlertDialog( @@ -118,6 +134,26 @@ fun SplitTunnelAppPickerView( selectedPackageNames.count(), )) } + item("search") { + OutlinedTextField( + value = searchQuery, + onValueChange = model::updateSearchQuery, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + singleLine = true, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { model.updateSearchQuery("") }) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.clear_search), + ) + } + } + }, + placeholder = { Text(stringResource(R.string.search_ellipsis)) }, + ) + } if (installedApps.isEmpty()) { item("spinner") { Box( @@ -132,44 +168,71 @@ fun SplitTunnelAppPickerView( } } } else { - items(installedApps, key = { it.packageName }) { app -> - ListItem( - headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, - leadingContent = { - Image( - bitmap = - model.installedAppsManager.packageManager - .getApplicationIcon(app.packageName) - .toBitmap() - .asImageBitmap(), - contentDescription = null, - modifier = Modifier.width(40.dp).height(40.dp), - ) - }, - supportingContent = { - Text( - app.packageName, - color = MaterialTheme.colorScheme.secondary, - fontSize = MaterialTheme.typography.bodySmall.fontSize, - letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing, - ) - }, - trailingContent = { - Checkbox( - checked = selectedPackageNames.contains(app.packageName), - enabled = !builtInDisallowedPackageNames.contains(app.packageName), - onCheckedChange = { checked -> - if (checked) { - model.select(packageName = app.packageName) - } else { - model.deselect(packageName = app.packageName) - } - }, - ) - }, - ) + items(displayedApps, key = { it.packageName }, contentType = { "app" }) { app -> + val selected = selectedPackageNames.contains(app.packageName) + val enabled = !builtInDisallowedPackageNames.contains(app.packageName) + + Row( + modifier = Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val appIcon = appIcons[app.packageName] + if (appIcon != null) { + Image( + bitmap = appIcon.asImageBitmap(), + contentDescription = null, + modifier = Modifier.size(appIconSize), + ) + } else { + Box( + modifier = + Modifier.size(appIconSize) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + app.name, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + app.packageName, + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Checkbox( + checked = selected, + enabled = enabled, + onCheckedChange = { checked -> + if (checked) { + model.select(packageName = app.packageName) + } else { + model.deselect(packageName = app.packageName) + } + }, + ) + } Lists.ItemDivider() } + if (displayedApps.isEmpty()) { + item("noResults") { + ListItem( + headlineContent = { + Text( + stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.secondary, + ) + }) + } + } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt index 8b0522a3b4..dbbdc33257 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt @@ -3,6 +3,7 @@ package com.tailscale.ipn.ui.viewModel +import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tailscale.ipn.App @@ -10,6 +11,7 @@ import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.SettingState import com.tailscale.ipn.ui.util.InstalledApp import com.tailscale.ipn.ui.util.InstalledAppsManager +import com.tailscale.ipn.ui.util.orderedSplitTunnelApps import com.tailscale.ipn.ui.util.set import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -17,6 +19,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn @@ -36,7 +39,18 @@ class SplitTunnelAppPickerViewModel : ViewModel() { started = SharingStarted.WhileSubscribed(5000), initialValue = listOf(), ) - val selectedPackageNames: StateFlow> = MutableStateFlow(listOf()) + val selectedPackageNames: StateFlow> = MutableStateFlow(emptySet()) + val searchQuery: StateFlow = MutableStateFlow("") + val appIcons: StateFlow> = MutableStateFlow(emptyMap()) + val displayedApps: StateFlow> = + combine(installedApps, selectedPackageNames, searchQuery) { apps, selectedPackages, query -> + orderedSplitTunnelApps(apps, selectedPackages, query) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = listOf(), + ) val allowSelected: StateFlow = MutableStateFlow(App.get().allowSelectedPackages()) val showHeaderMenu: StateFlow = MutableStateFlow(false) @@ -46,6 +60,7 @@ class SplitTunnelAppPickerViewModel : ViewModel() { val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow private var saveJob: Job? = null + private var preloadIconsJob: Job? = null private fun initSelectedPackageNames() { allowSelected.set(App.get().allowSelectedPackages()) @@ -60,7 +75,7 @@ class SplitTunnelAppPickerViewModel : ViewModel() { } } .intersect(installedApps.value.map { it.packageName }.toSet()) - .toList()) + .toSet()) } fun performSelectionSwitch() { @@ -80,12 +95,37 @@ class SplitTunnelAppPickerViewModel : ViewModel() { debounceSave() } + fun updateSearchQuery(query: String) { + searchQuery.set(query) + } + + fun preloadIcons(apps: List, sizePx: Int) { + if (apps.isEmpty() || sizePx <= 0) return + + preloadIconsJob?.cancel() + preloadIconsJob = + viewModelScope.launch(Dispatchers.IO) { + val missingApps = apps.filterNot { appIcons.value.containsKey(it.packageName) } + val loadedIcons = mutableMapOf() + + missingApps.forEachIndexed { index, app -> + loadedIcons[app.packageName] = + installedAppsManager.iconForPackage(app.packageName, sizePx) + + if (loadedIcons.size >= 20 || index == missingApps.lastIndex) { + appIcons.set(appIcons.value + loadedIcons) + loadedIcons.clear() + } + } + } + } + private fun debounceSave() { saveJob?.cancel() saveJob = viewModelScope.launch { delay(500) // Wait to batch multiple rapid updates - App.get().updateUserSelectedPackages(selectedPackageNames.value) + App.get().updateUserSelectedPackages(selectedPackageNames.value.toList()) } } } diff --git a/android/src/test/kotlin/com/tailscale/ipn/ui/util/SplitTunnelAppOrderingTest.kt b/android/src/test/kotlin/com/tailscale/ipn/ui/util/SplitTunnelAppOrderingTest.kt new file mode 100644 index 0000000000..2263358077 --- /dev/null +++ b/android/src/test/kotlin/com/tailscale/ipn/ui/util/SplitTunnelAppOrderingTest.kt @@ -0,0 +1,247 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SplitTunnelAppOrderingTest { + private val apps = + listOf( + InstalledApp("2ГИС beta", "ru.dublgis.dgismobile4preview"), + InstalledApp("AIMP", "com.aimp.player"), + InstalledApp("Яндекс Музыка", "ru.yandex.music"), + InstalledApp("Airbnb", "com.airbnb.android"), + InstalledApp("百度地图", "com.baidu.BaiduMap"), + InstalledApp("Chrome", "com.android.chrome"), + ) + + @Test + fun unselectedAppsAreOrderedWithEnglishNamesBeforeLocalizedNames() { + val ordered = orderedSplitTunnelApps(apps, selectedPackageNames = emptySet(), query = "") + + assertEquals( + listOf( + "com.aimp.player", + "com.airbnb.android", + "com.android.chrome", + "ru.dublgis.dgismobile4preview", + "ru.yandex.music", + "com.baidu.BaiduMap", + ), + ordered.map { it.packageName }, + ) + } + + @Test + fun selectedAppsStayAboveUnselectedAppsAndAreAlsoEnglishFirst() { + val ordered = + orderedSplitTunnelApps( + apps, + selectedPackageNames = + setOf("ru.yandex.music", "com.android.chrome", "ru.dublgis.dgismobile4preview"), + query = "", + ) + + assertEquals( + listOf( + "com.android.chrome", + "ru.dublgis.dgismobile4preview", + "ru.yandex.music", + "com.aimp.player", + "com.airbnb.android", + "com.baidu.BaiduMap", + ), + ordered.map { it.packageName }, + ) + } + + @Test + fun searchMatchesAppNameCaseInsensitivelyAndKeepsOrdering() { + val ordered = orderedSplitTunnelApps(apps, selectedPackageNames = emptySet(), query = "AIR") + + assertEquals(listOf("com.airbnb.android"), ordered.map { it.packageName }) + } + + @Test + fun searchMatchesPackageNameCaseInsensitively() { + val ordered = orderedSplitTunnelApps(apps, selectedPackageNames = emptySet(), query = "YANDEX") + + assertEquals(listOf("ru.yandex.music"), ordered.map { it.packageName }) + } + + @Test + fun localizedQueryMatchesLocalizedAppName() { + val ordered = orderedSplitTunnelApps(apps, selectedPackageNames = emptySet(), query = "музыка") + + assertEquals(listOf("ru.yandex.music"), ordered.map { it.packageName }) + } + + @Test + fun namesStartingWithDigitsUseTheFirstLetterForLanguageGroup() { + val ordered = + orderedSplitTunnelApps( + listOf( + InstalledApp("2ГИС beta", "ru.dublgis.dgismobile4preview"), + InstalledApp("2GIS", "ru.dublgis.dgismobile"), + ), + selectedPackageNames = emptySet(), + query = "", + ) + + assertEquals( + listOf("ru.dublgis.dgismobile", "ru.dublgis.dgismobile4preview"), + ordered.map { it.packageName }, + ) + } + + @Test + fun orderWithinEnglishAndLocalizedGroupsStaysStable() { + val ordered = + orderedSplitTunnelApps( + listOf( + InstalledApp("Zoo", "com.example.zoo"), + InstalledApp("Alpha", "com.example.alpha"), + InstalledApp("Яндекс", "ru.yandex"), + InstalledApp("Альфа", "ru.alpha"), + InstalledApp("Beta", "com.example.beta"), + ), + selectedPackageNames = emptySet(), + query = "", + ) + + assertEquals( + listOf("com.example.zoo", "com.example.alpha", "com.example.beta", "ru.yandex", "ru.alpha"), + ordered.map { it.packageName }, + ) + } + + @Test + fun selectedPackageNamesThatAreNotInTheAppListAreIgnored() { + val ordered = + orderedSplitTunnelApps( + apps, + selectedPackageNames = setOf("missing.package", "com.airbnb.android"), + query = "", + ) + + assertEquals("com.airbnb.android", ordered.first().packageName) + assertEquals(apps.size, ordered.size) + } + + @Test + fun blankSearchQueryBehavesLikeNoSearch() { + val noQuery = orderedSplitTunnelApps(apps, selectedPackageNames = emptySet(), query = "") + val blankQuery = orderedSplitTunnelApps(apps, selectedPackageNames = emptySet(), query = " ") + + assertEquals(noQuery, blankQuery) + } + + @Test + fun searchQueryIsTrimmedBeforeMatching() { + val ordered = + orderedSplitTunnelApps(apps, selectedPackageNames = emptySet(), query = " chrome ") + + assertEquals(listOf("com.android.chrome"), ordered.map { it.packageName }) + } + + @Test + fun searchWithNoMatchesReturnsEmptyList() { + val ordered = + orderedSplitTunnelApps(apps, selectedPackageNames = emptySet(), query = "not-installed") + + assertTrue(ordered.isEmpty()) + } + + @Test + fun searchResultStillKeepsSelectedAppsAboveUnselectedApps() { + val ordered = + orderedSplitTunnelApps( + apps, + selectedPackageNames = setOf("com.baidu.BaiduMap"), + query = "com.", + ) + + assertEquals( + listOf( + "com.baidu.BaiduMap", + "com.aimp.player", + "com.airbnb.android", + "com.android.chrome", + ), + ordered.map { it.packageName }, + ) + } + + @Test + fun nonLetterPrefixesAreTreatedAsLocalizedWhenNoLetterExists() { + val ordered = + orderedSplitTunnelApps( + listOf( + InstalledApp("!!!", "punctuation.only"), + InstalledApp("Alpha", "latin.alpha"), + InstalledApp("123", "digits.only"), + ), + selectedPackageNames = emptySet(), + query = "", + ) + + assertEquals( + listOf("latin.alpha", "punctuation.only", "digits.only"), ordered.map { it.packageName }) + } + + @Test + fun volumeOrderingHandlesManyApps() { + val englishApps = + (0 until 5000).map { index -> + InstalledApp("English App $index", "com.example.english.$index") + } + val localizedApps = + (0 until 5000).map { index -> InstalledApp("Приложение $index", "ru.example.$index") } + val volumeApps = localizedApps.take(2500) + englishApps + localizedApps.drop(2500) + val selectedPackages = + setOf( + "ru.example.4999", + "com.example.english.4999", + "ru.example.1", + "com.example.english.1", + ) + + val ordered = orderedSplitTunnelApps(volumeApps, selectedPackages, query = "") + + assertEquals(10000, ordered.size) + assertEquals( + listOf( + "com.example.english.1", + "com.example.english.4999", + "ru.example.1", + "ru.example.4999", + ), + ordered.take(4).map { it.packageName }, + ) + assertEquals("com.example.english.0", ordered[4].packageName) + assertEquals("com.example.english.4998", ordered[5001].packageName) + assertEquals("ru.example.0", ordered[5002].packageName) + assertEquals("ru.example.4998", ordered.last().packageName) + } + + @Test + fun volumeSearchFiltersManyAppsBeforeOrdering() { + val volumeApps = + (0 until 10_000).map { index -> + val name = if (index % 2 == 0) "English App $index" else "Приложение $index" + InstalledApp(name, "com.example.app.$index") + } + + val ordered = + orderedSplitTunnelApps( + volumeApps, + selectedPackageNames = setOf("com.example.app.1999"), + query = "1999", + ) + + assertEquals(listOf("com.example.app.1999"), ordered.map { it.packageName }) + } +}