From 39e35941550d449b7bda7ab561c3acaf6c63d5e4 Mon Sep 17 00:00:00 2001 From: Pawloland <59684145+Pawloland@users.noreply.github.com> Date: Tue, 26 May 2026 16:54:55 +0200 Subject: [PATCH 1/2] Fix search crash on API 32 and older Signed-off-by: Pawloland <59684145+Pawloland@users.noreply.github.com> --- .../com/tailscale/ipn/ui/view/MainView.kt | 4 +--- .../com/tailscale/ipn/ui/view/SearchView.kt | 19 +++---------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index d2701e46a8..ac0b97faa7 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -562,10 +562,8 @@ fun PeerList( var isListFocussed by remember { mutableStateOf(false) } val expandedPeer = viewModel.expandedMenuPeer.collectAsState() val localClipboardManager = LocalClipboardManager.current - // Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375) - val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU Column(modifier = Modifier.fillMaxSize()) { - if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { + if (FeatureFlags.isEnabled("enable_new_search")) { Search(onSearchBarClick) } else { if (!isAndroidTV()) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt index 3cc69fbc0b..bf10c44cc9 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -3,11 +3,7 @@ package com.tailscale.ipn.ui.view -import android.app.Activity -import android.os.Build -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.annotation.RequiresApi +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -33,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -45,7 +40,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext + import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -60,7 +55,6 @@ import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.MainViewModel import kotlinx.coroutines.delay -@RequiresApi(Build.VERSION_CODES.TIRAMISU) @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchView( @@ -80,7 +74,6 @@ fun SearchView( val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current var expanded by rememberSaveable { mutableStateOf(true) } - val context = LocalContext.current as Activity val listState = rememberLazyListState() val noResultsBackground = @@ -90,19 +83,13 @@ fun SearchView( MaterialTheme.colorScheme.surfaceContainer // color for light mode } - val callback = OnBackInvokedCallback { + BackHandler { focusManager.clearFocus(force = true) keyboardController?.hide() onNavigateBack() viewModel.updateSearchTerm("") } - DisposableEffect(Unit) { - val dispatcher = context.onBackInvokedDispatcher - dispatcher?.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, callback) - onDispose { dispatcher?.unregisterOnBackInvokedCallback(callback) } - } - LaunchedEffect(searchTerm, filteredPeers) { if (searchTerm.isEmpty() && filteredPeers.isNotEmpty()) { delay(100) // Give Compose time to update list From 35198bfaec036fa75fee017b3fd92197fc1cfcf7 Mon Sep 17 00:00:00 2001 From: Pawloland <59684145+Pawloland@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:51:58 +0200 Subject: [PATCH 2/2] Fix format Signed-off-by: Pawloland <59684145+Pawloland@users.noreply.github.com> --- .../com/tailscale/ipn/ui/view/MainView.kt | 1 - .../com/tailscale/ipn/ui/view/SearchView.kt | 289 +++++++++--------- 2 files changed, 141 insertions(+), 149 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index c073624b80..a3a53f8452 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -2,7 +2,6 @@ // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn.ui.view -import android.os.Build import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt index 7ea1f848dc..9e67c4d678 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -3,12 +3,7 @@ package com.tailscale.ipn.ui.view -import android.app.Activity -import android.os.Build -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher import androidx.activity.compose.BackHandler -import androidx.annotation.RequiresApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -34,7 +29,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -46,7 +40,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester - import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -69,160 +62,160 @@ fun SearchView( onNavigateBack: () -> Unit, autoFocus: Boolean // Pass true if coming from the main view, false otherwise. ) { - // Use TextFieldValue to preserve text and cursor position. - var searchFieldValue by - rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } - val searchTerm = searchFieldValue.text + // Use TextFieldValue to preserve text and cursor position. + var searchFieldValue by + rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } + val searchTerm = searchFieldValue.text - val filteredPeers by viewModel.searchViewPeers.collectAsState() - val netmap by viewModel.netmap.collectAsState() - val keyboardController = LocalSoftwareKeyboardController.current - val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - var expanded by rememberSaveable { mutableStateOf(true) } - val listState = rememberLazyListState() + val filteredPeers by viewModel.searchViewPeers.collectAsState() + val netmap by viewModel.netmap.collectAsState() + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + var expanded by rememberSaveable { mutableStateOf(true) } + val listState = rememberLazyListState() - val noResultsBackground = - if (isSystemInDarkTheme()) { - MaterialTheme.colorScheme.surface // color for dark mode - } else { - MaterialTheme.colorScheme.surfaceContainer // color for light mode - } + val noResultsBackground = + if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.surface // color for dark mode + } else { + MaterialTheme.colorScheme.surfaceContainer // color for light mode + } - BackHandler { - focusManager.clearFocus(force = true) - keyboardController?.hide() - onNavigateBack() - viewModel.updateSearchTerm("") - } + BackHandler { + focusManager.clearFocus(force = true) + keyboardController?.hide() + onNavigateBack() + viewModel.updateSearchTerm("") + } - LaunchedEffect(searchTerm, filteredPeers) { - if (searchTerm.isEmpty() && filteredPeers.isNotEmpty()) { - delay(100) // Give Compose time to update list - listState.scrollToItem(0) - } + LaunchedEffect(searchTerm, filteredPeers) { + if (searchTerm.isEmpty() && filteredPeers.isNotEmpty()) { + delay(100) // Give Compose time to update list + listState.scrollToItem(0) } + } - // Use the autoFocus parameter to decide if we request focus when entering. - LaunchedEffect(autoFocus) { - if (autoFocus) { - delay(300) // Delay to ensure UI is fully composed - focusRequester.requestFocus() - keyboardController?.show() - } + // Use the autoFocus parameter to decide if we request focus when entering. + LaunchedEffect(autoFocus) { + if (autoFocus) { + delay(300) // Delay to ensure UI is fully composed + focusRequester.requestFocus() + keyboardController?.show() } + } - Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxWidth().focusRequester(focusRequester)) { - SearchBar( - modifier = Modifier.fillMaxWidth(), - query = searchTerm, - onQueryChange = { newQuery -> - // Create a new TextFieldValue with updated text and set cursor to the end. - searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length)) - viewModel.updateSearchTerm(newQuery) - expanded = true - }, - onSearch = { newQuery -> - searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length)) - viewModel.updateSearchTerm(newQuery) + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxWidth().focusRequester(focusRequester)) { + SearchBar( + modifier = Modifier.fillMaxWidth(), + query = searchTerm, + onQueryChange = { newQuery -> + // Create a new TextFieldValue with updated text and set cursor to the end. + searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length)) + viewModel.updateSearchTerm(newQuery) + expanded = true + }, + onSearch = { newQuery -> + searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length)) + viewModel.updateSearchTerm(newQuery) + focusManager.clearFocus() + keyboardController?.hide() + }, + placeholder = { Text(text = stringResource(R.string.search)) }, + leadingIcon = { + IconButton( + onClick = { + focusManager.clearFocus() + onNavigateBack() + viewModel.updateSearchTerm("") + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.search), + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { + IconButton( + onClick = { + searchFieldValue = TextFieldValue("", selection = TextRange(0)) + viewModel.updateSearchTerm("") focusManager.clearFocus() keyboardController?.hide() - }, - placeholder = { Text(text = stringResource(R.string.search)) }, - leadingIcon = { - IconButton( - onClick = { - focusManager.clearFocus() - onNavigateBack() - viewModel.updateSearchTerm("") - }) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - }, - trailingIcon = { - if (searchTerm.isNotEmpty()) { - IconButton( - onClick = { - searchFieldValue = TextFieldValue("", selection = TextRange(0)) - viewModel.updateSearchTerm("") - focusManager.clearFocus() - keyboardController?.hide() - }) { - Icon( - Icons.Default.Clear, - contentDescription = stringResource(R.string.clear_search)) - } + }) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(R.string.clear_search)) + } + } + }, + active = expanded, + onActiveChange = { expanded = it }, + content = { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + if (filteredPeers.isEmpty()) { + // When there are no filtered peers, show a "No results" message. + item { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Lists.LargeTitle( + stringResource(id = R.string.no_results), + bottomPadding = 8.dp, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Light, + backgroundColor = noResultsBackground, + fontColor = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } else { + var firstGroup = true + filteredPeers.forEach { peerSet -> + if (!firstGroup) { + item { Lists.ItemDivider() } + } + firstGroup = false + + val userName = peerSet.user?.DisplayName ?: "Unknown User" + peerSet.peers.forEachIndexed { index, peer -> + if (index > 0) { + item(key = "divider_${peer.StableID}") { Lists.ItemDivider() } } - }, - active = expanded, - onActiveChange = { expanded = it }, - content = { - LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { - if (filteredPeers.isEmpty()) { - // When there are no filtered peers, show a "No results" message. - item { - Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Lists.LargeTitle( - stringResource(id = R.string.no_results), - bottomPadding = 8.dp, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Light, - backgroundColor = noResultsBackground, - fontColor = MaterialTheme.colorScheme.onSurfaceVariant) - } + item(key = "peer_${peer.StableID}") { + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + val onlineColor = peer.connectedColor(netmap) + Box( + modifier = + Modifier.size(10.dp) + .background(onlineColor, RoundedCornerShape(50))) + Spacer(modifier = Modifier.size(8.dp)) + Text(peer.displayName ?: "Unknown Device") + } } - } else { - var firstGroup = true - filteredPeers.forEach { peerSet -> - if (!firstGroup) { - item { Lists.ItemDivider() } - } - firstGroup = false - - val userName = peerSet.user?.DisplayName ?: "Unknown User" - peerSet.peers.forEachIndexed { index, peer -> - if (index > 0) { - item(key = "divider_${peer.StableID}") { Lists.ItemDivider() } - } - item(key = "peer_${peer.StableID}") { - ListItem( - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - val onlineColor = peer.connectedColor(netmap) - Box( - modifier = - Modifier.size(10.dp) - .background(onlineColor, RoundedCornerShape(50))) - Spacer(modifier = Modifier.size(8.dp)) - Text(peer.displayName ?: "Unknown Device") - } - } - }, - supportingContent = { - Column { - Text(userName) - Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP") - } - }, - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 0.dp) - .clickable { - viewModel.disableSearchAutoFocus() - navController.navigate("peerDetails/${peer.StableID}") - }) - } - } + }, + supportingContent = { + Column { + Text(userName) + Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP") } - } + }, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 0.dp) + .clickable { + viewModel.disableSearchAutoFocus() + navController.navigate("peerDetails/${peer.StableID}") + }) } - }) - } + } + } + } + } + }) } + } }