Skip to content

Commit 0bd812a

Browse files
committed
android: optimize split-tunnel app picker
1 parent b83495b commit 0bd812a

5 files changed

Lines changed: 464 additions & 47 deletions

File tree

android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,56 @@ package com.tailscale.ipn.ui.util
66
import android.Manifest
77
import android.content.pm.ApplicationInfo
88
import android.content.pm.PackageManager
9+
import android.graphics.Bitmap
10+
import android.util.LruCache
11+
import androidx.core.graphics.drawable.toBitmap
912
import com.tailscale.ipn.BuildConfig
13+
import java.text.Collator
1014

1115
data class InstalledApp(val name: String, val packageName: String)
1216

1317
class InstalledAppsManager(
1418
val packageManager: PackageManager,
1519
) {
20+
private val iconCache =
21+
object : LruCache<String, Bitmap>(4 * 1024) {
22+
override fun sizeOf(key: String, value: Bitmap): Int {
23+
return value.allocationByteCount / 1024
24+
}
25+
}
26+
1627
fun fetchInstalledApps(): List<InstalledApp> {
28+
val collator = Collator.getInstance()
1729
return packageManager
18-
.getInstalledApplications(PackageManager.GET_META_DATA)
30+
.getInstalledApplications(0)
1931
.filter(appIsIncluded)
20-
.map {
21-
InstalledApp(
22-
name = it.loadLabel(packageManager).toString(),
23-
packageName = it.packageName,
24-
)
32+
.mapNotNull { app ->
33+
runCatching {
34+
InstalledApp(
35+
name = app.loadLabel(packageManager).toString(),
36+
packageName = app.packageName,
37+
)
38+
}
39+
.getOrNull()
40+
}
41+
.sortedWith { left, right ->
42+
val nameComparison = collator.compare(left.name, right.name)
43+
if (nameComparison != 0) nameComparison else left.packageName.compareTo(right.packageName)
2544
}
26-
.sortedBy { it.name }
45+
}
46+
47+
fun iconForPackage(packageName: String, sizePx: Int): Bitmap {
48+
val cacheKey = "$packageName:$sizePx"
49+
iconCache.get(cacheKey)?.let {
50+
return it
51+
}
52+
53+
val icon =
54+
runCatching { packageManager.getApplicationIcon(packageName) }
55+
.getOrElse { packageManager.defaultActivityIcon }
56+
.toBitmap(width = sizePx, height = sizePx, config = Bitmap.Config.ARGB_8888)
57+
iconCache.put(cacheKey, icon)
58+
return icon
2759
}
2860

2961
private val appIsIncluded: (ApplicationInfo) -> Boolean = { app ->
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.util
5+
6+
fun orderedSplitTunnelApps(
7+
apps: List<InstalledApp>,
8+
selectedPackageNames: Set<String>,
9+
query: String,
10+
): List<InstalledApp> {
11+
val filteredApps =
12+
if (query.isBlank()) {
13+
apps
14+
} else {
15+
val normalizedQuery = query.trim()
16+
apps.filter { app ->
17+
app.name.contains(normalizedQuery, ignoreCase = true) ||
18+
app.packageName.contains(normalizedQuery, ignoreCase = true)
19+
}
20+
}
21+
22+
val (selectedApps, unselectedApps) =
23+
filteredApps.partition { selectedPackageNames.contains(it.packageName) }
24+
return selectedApps.englishNamesFirst() + unselectedApps.englishNamesFirst()
25+
}
26+
27+
private fun List<InstalledApp>.englishNamesFirst(): List<InstalledApp> {
28+
val (englishApps, localizedApps) = partition { it.name.startsWithLatinLetter() }
29+
return englishApps + localizedApps
30+
}
31+
32+
private fun String.startsWithLatinLetter(): Boolean {
33+
val firstLetter = firstOrNull { it.isLetter() } ?: return false
34+
return Character.UnicodeScript.of(firstLetter.code) == Character.UnicodeScript.LATIN
35+
}

android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt

Lines changed: 100 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,21 @@ package com.tailscale.ipn.ui.view
66
import androidx.compose.foundation.Image
77
import androidx.compose.foundation.background
88
import androidx.compose.foundation.layout.Box
9+
import androidx.compose.foundation.layout.Column
910
import androidx.compose.foundation.layout.Row
11+
import androidx.compose.foundation.layout.Spacer
1012
import androidx.compose.foundation.layout.fillMaxSize
13+
import androidx.compose.foundation.layout.fillMaxWidth
1114
import androidx.compose.foundation.layout.height
1215
import androidx.compose.foundation.layout.padding
16+
import androidx.compose.foundation.layout.size
1317
import androidx.compose.foundation.layout.width
1418
import androidx.compose.foundation.lazy.LazyColumn
1519
import androidx.compose.foundation.lazy.items
1620
import androidx.compose.material.icons.Icons
1721
import androidx.compose.material.icons.filled.MoreVert
22+
import androidx.compose.material.icons.outlined.Close
23+
import androidx.compose.material.icons.outlined.Search
1824
import androidx.compose.material3.AlertDialog
1925
import androidx.compose.material3.Checkbox
2026
import androidx.compose.material3.CircularProgressIndicator
@@ -23,19 +29,22 @@ import androidx.compose.material3.Icon
2329
import androidx.compose.material3.IconButton
2430
import androidx.compose.material3.ListItem
2531
import androidx.compose.material3.MaterialTheme
32+
import androidx.compose.material3.OutlinedTextField
2633
import androidx.compose.material3.Scaffold
2734
import androidx.compose.material3.Text
2835
import androidx.compose.material3.TextButton
2936
import androidx.compose.runtime.Composable
37+
import androidx.compose.runtime.LaunchedEffect
3038
import androidx.compose.runtime.collectAsState
3139
import androidx.compose.runtime.getValue
3240
import androidx.compose.ui.Alignment
3341
import androidx.compose.ui.Modifier
3442
import androidx.compose.ui.graphics.asImageBitmap
43+
import androidx.compose.ui.platform.LocalDensity
3544
import androidx.compose.ui.res.stringResource
3645
import androidx.compose.ui.text.font.FontWeight
46+
import androidx.compose.ui.text.style.TextOverflow
3747
import androidx.compose.ui.unit.dp
38-
import androidx.core.graphics.drawable.toBitmap
3948
import androidx.lifecycle.viewmodel.compose.viewModel
4049
import com.tailscale.ipn.App
4150
import com.tailscale.ipn.R
@@ -49,13 +58,20 @@ fun SplitTunnelAppPickerView(
4958
model: SplitTunnelAppPickerViewModel = viewModel(),
5059
) {
5160
val installedApps by model.installedApps.collectAsState()
61+
val displayedApps by model.displayedApps.collectAsState()
5262
val selectedPackageNames by model.selectedPackageNames.collectAsState()
63+
val searchQuery by model.searchQuery.collectAsState()
64+
val appIcons by model.appIcons.collectAsState()
5365
val allowSelected by model.allowSelected.collectAsState()
5466
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
5567
val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
5668
val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
5769
val showHeaderMenu by model.showHeaderMenu.collectAsState()
5870
val showSwitchDialog by model.showSwitchDialog.collectAsState()
71+
val appIconSize = 40.dp
72+
val appIconSizePx = with(LocalDensity.current) { appIconSize.roundToPx() }
73+
74+
LaunchedEffect(installedApps, appIconSizePx) { model.preloadIcons(installedApps, appIconSizePx) }
5975

6076
if (showSwitchDialog) {
6177
SwitchAlertDialog(
@@ -118,6 +134,26 @@ fun SplitTunnelAppPickerView(
118134
selectedPackageNames.count(),
119135
))
120136
}
137+
item("search") {
138+
OutlinedTextField(
139+
value = searchQuery,
140+
onValueChange = model::updateSearchQuery,
141+
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
142+
singleLine = true,
143+
leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) },
144+
trailingIcon = {
145+
if (searchQuery.isNotEmpty()) {
146+
IconButton(onClick = { model.updateSearchQuery("") }) {
147+
Icon(
148+
Icons.Outlined.Close,
149+
contentDescription = stringResource(R.string.clear_search),
150+
)
151+
}
152+
}
153+
},
154+
placeholder = { Text(stringResource(R.string.search_ellipsis)) },
155+
)
156+
}
121157
if (installedApps.isEmpty()) {
122158
item("spinner") {
123159
Box(
@@ -132,44 +168,71 @@ fun SplitTunnelAppPickerView(
132168
}
133169
}
134170
} else {
135-
items(installedApps, key = { it.packageName }) { app ->
136-
ListItem(
137-
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
138-
leadingContent = {
139-
Image(
140-
bitmap =
141-
model.installedAppsManager.packageManager
142-
.getApplicationIcon(app.packageName)
143-
.toBitmap()
144-
.asImageBitmap(),
145-
contentDescription = null,
146-
modifier = Modifier.width(40.dp).height(40.dp),
147-
)
148-
},
149-
supportingContent = {
150-
Text(
151-
app.packageName,
152-
color = MaterialTheme.colorScheme.secondary,
153-
fontSize = MaterialTheme.typography.bodySmall.fontSize,
154-
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing,
155-
)
156-
},
157-
trailingContent = {
158-
Checkbox(
159-
checked = selectedPackageNames.contains(app.packageName),
160-
enabled = !builtInDisallowedPackageNames.contains(app.packageName),
161-
onCheckedChange = { checked ->
162-
if (checked) {
163-
model.select(packageName = app.packageName)
164-
} else {
165-
model.deselect(packageName = app.packageName)
166-
}
167-
},
168-
)
169-
},
170-
)
171+
items(displayedApps, key = { it.packageName }, contentType = { "app" }) { app ->
172+
val selected = selectedPackageNames.contains(app.packageName)
173+
val enabled = !builtInDisallowedPackageNames.contains(app.packageName)
174+
175+
Row(
176+
modifier = Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 16.dp),
177+
verticalAlignment = Alignment.CenterVertically,
178+
) {
179+
val appIcon = appIcons[app.packageName]
180+
if (appIcon != null) {
181+
Image(
182+
bitmap = appIcon.asImageBitmap(),
183+
contentDescription = null,
184+
modifier = Modifier.size(appIconSize),
185+
)
186+
} else {
187+
Box(
188+
modifier =
189+
Modifier.size(appIconSize)
190+
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
191+
)
192+
}
193+
Spacer(modifier = Modifier.width(16.dp))
194+
Column(modifier = Modifier.weight(1f)) {
195+
Text(
196+
app.name,
197+
color = MaterialTheme.colorScheme.onSurface,
198+
fontWeight = FontWeight.SemiBold,
199+
maxLines = 1,
200+
overflow = TextOverflow.Ellipsis,
201+
)
202+
Text(
203+
app.packageName,
204+
color = MaterialTheme.colorScheme.secondary,
205+
fontSize = MaterialTheme.typography.bodySmall.fontSize,
206+
letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing,
207+
maxLines = 1,
208+
overflow = TextOverflow.Ellipsis,
209+
)
210+
}
211+
Checkbox(
212+
checked = selected,
213+
enabled = enabled,
214+
onCheckedChange = { checked ->
215+
if (checked) {
216+
model.select(packageName = app.packageName)
217+
} else {
218+
model.deselect(packageName = app.packageName)
219+
}
220+
},
221+
)
222+
}
171223
Lists.ItemDivider()
172224
}
225+
if (displayedApps.isEmpty()) {
226+
item("noResults") {
227+
ListItem(
228+
headlineContent = {
229+
Text(
230+
stringResource(R.string.no_results),
231+
color = MaterialTheme.colorScheme.secondary,
232+
)
233+
})
234+
}
235+
}
173236
}
174237
}
175238
}

0 commit comments

Comments
 (0)