From d8f28f4673e1a99e55a7d45943c40117a078b296 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Tue, 16 Jun 2026 14:31:26 -0400 Subject: [PATCH 1/3] android: fix Kotlin compiler warnings (#779) fixes tailscale/corp#40476 Correcting all of the compiler warnings and code formatting issues. Signed-off-by: Jonathan Nobels --- .../src/main/java/com/tailscale/ipn/App.kt | 9 +- .../java/com/tailscale/ipn/MainActivity.kt | 20 +- .../java/com/tailscale/ipn/ui/util/Lists.kt | 7 +- .../tailscale/ipn/ui/view/BugReportView.kt | 12 +- .../com/tailscale/ipn/ui/view/MainView.kt | 3 +- .../com/tailscale/ipn/ui/view/SearchView.kt | 215 +++++++++--------- .../ipn/ui/view/TailnetLockSetupView.kt | 12 +- .../tailscale/ipn/ui/view/UserSwitcherView.kt | 26 +-- .../com/tailscale/ipn/util/ShareFileHelper.kt | 11 +- .../main/java/com/tailscale/ipn/util/TSLog.kt | 2 +- 10 files changed, 147 insertions(+), 170 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 11e8b4de57..2d3ba020f3 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -52,7 +52,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -174,8 +173,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) initViewModels() applicationScope.launch { - val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager - MDMSettings.update(get(), rm) + val restrictionsManager = + getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + MDMSettings.update(get(), restrictionsManager) } applicationScope.launch { Notifier.state.collect { _ -> @@ -208,9 +208,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } } } - applicationScope.launch { - val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() - } TSLog.init(this) FeatureFlags.initialize(mapOf("enable_new_search" to true)) } diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 7b7181e0d2..a338dc8011 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -337,7 +337,7 @@ class MainActivity : ComponentActivity() { loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel, - appViewModel = appViewModel) + ) } composable("search") { val autoFocus = viewModel.autoFocusSearch @@ -473,17 +473,13 @@ class MainActivity : ComponentActivity() { if (this::navController.isInitialized) { val previousEntry = navController.previousBackStackEntry TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") - if (this::navController.isInitialized) { - val previousEntry = navController.previousBackStackEntry - TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") - if (previousEntry != null) { - navController.popBackStack(route = "main", inclusive = false) - } else { - TSLog.e( - "MainActivity", - "onNewIntent: No previous back stack entry, navigating directly to 'main'") - navController.navigate("main") { popUpTo("main") { inclusive = true } } - } + if (previousEntry != null) { + navController.popBackStack(route = "main", inclusive = false) + } else { + TSLog.e( + "MainActivity", + "onNewIntent: No previous back stack entry, navigating directly to 'main'") + navController.navigate("main") { popUpTo("main") { inclusive = true } } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt index 71415c0c3b..8cbc393e80 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt @@ -4,6 +4,7 @@ package com.tailscale.ipn.ui.util import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -11,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -95,7 +95,10 @@ object Lists { headlineContent = { Box(modifier = Modifier.padding(vertical = 4.dp)) { onClick?.let { - ClickableText(text = text as AnnotatedString, style = style, onClick = { onClick() }) + Text( + text = text as AnnotatedString, + style = style, + modifier = Modifier.clickable { onClick() }) } ?: run { Text(text as String, style = style) } } }) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt index fce52b476c..5d452a071c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt @@ -8,17 +8,17 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration @@ -36,7 +36,6 @@ import com.tailscale.ipn.ui.viewModel.BugReportViewModel @Composable fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) { - val handler = LocalUriHandler.current val bugReportID by model.bugReportID.collectAsState() Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding @@ -48,10 +47,7 @@ fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = vi .fillMaxHeight() .verticalScroll(rememberScrollState())) { Lists.MultilineDescription { - ClickableText( - text = contactText(), - style = MaterialTheme.typography.bodyMedium, - onClick = { handler.openUri(Links.SUPPORT_URL) }) + Text(text = contactText(), style = MaterialTheme.typography.bodyMedium) } ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id)) @@ -68,7 +64,7 @@ fun contactText(): AnnotatedString { append(stringResource(id = R.string.bug_report_instructions_prefix)) } - pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) + pushLink(LinkAnnotation.Url(Links.SUPPORT_URL)) withStyle( style = SpanStyle( 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 82ec3797a4..23a22b2cd5 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 @@ -129,7 +129,6 @@ fun MainView( loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewModel: MainViewModel, - appViewModel: AppViewModel ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState() @@ -829,5 +828,5 @@ fun MainViewPreview() { onNavigateToHealth = {}, onNavigateToSearch = {}), vm, - appViewModel) + ) } 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..b5e7897e32 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 @@ -23,7 +23,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -31,6 +31,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -99,8 +100,8 @@ fun SearchView( DisposableEffect(Unit) { val dispatcher = context.onBackInvokedDispatcher - dispatcher?.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, callback) - onDispose { dispatcher?.unregisterOnBackInvokedCallback(callback) } + dispatcher.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, callback) + onDispose { dispatcher.unregisterOnBackInvokedCallback(callback) } } LaunchedEffect(searchTerm, filteredPeers) { @@ -123,113 +124,121 @@ fun SearchView( 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 = { + inputField = { + SearchBarDefaults.InputField( + 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() - 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)) + keyboardController?.hide() + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + placeholder = { Text(text = stringResource(R.string.search)) }, + leadingIcon = { + IconButton( + onClick = { + focusManager.clearFocus() + onNavigateBack() + viewModel.updateSearchTerm("") + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.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)) + } } - } + }, + ) }, - 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 + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + 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() } - } - 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}") - }) - } - } + 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) + } + } + }, + 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}") + }) } } } - }) + } + } + } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt index e8e851aa1c..d0f94bff5d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/TailnetLockSetupView.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -22,10 +21,10 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration @@ -96,13 +95,8 @@ fun TailnetLockSetupView( @Composable private fun ExplainerView() { - val handler = LocalUriHandler.current - Lists.MultilineDescription { - ClickableText( - explainerText(), - onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) }, - style = MaterialTheme.typography.bodyMedium) + Text(text = explainerText(), style = MaterialTheme.typography.bodyMedium) } } @@ -113,7 +107,7 @@ fun explainerText(): AnnotatedString { append(stringResource(id = R.string.tailnet_lock_explainer)) } - pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL) + pushLink(LinkAnnotation.Url(Links.TAILNET_LOCK_KB_URL)) withStyle( style = diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index 64d4bd4d9c..2e1b4e4a2b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog @@ -34,6 +33,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle @@ -168,10 +168,7 @@ fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = vi title = { Text(text = stringResource(R.string.delete_tailnet)) }, text = { if (isOwner) { - OwnerDeleteDialogText { - val uri = Uri.parse("https://login.tailscale.com/admin/settings/general") - context.startActivity(Intent(Intent.ACTION_VIEW, uri)) - } + OwnerDeleteDialogText() } else { Text(stringResource(R.string.request_deletion_nonowner)) } @@ -223,7 +220,7 @@ fun FusMenu( } @Composable -fun OwnerDeleteDialogText(onSettingsClick: () -> Unit) { +fun OwnerDeleteDialogText() { val part1 = stringResource(R.string.request_deletion_owner_part1) val part2a = stringResource(R.string.request_deletion_owner_part2a) val part2b = stringResource(R.string.request_deletion_owner_part2b) @@ -231,8 +228,7 @@ fun OwnerDeleteDialogText(onSettingsClick: () -> Unit) { val annotatedText = buildAnnotatedString { append(part1 + " ") - pushStringAnnotation( - tag = "settings", annotation = "https://login.tailscale.com/admin/settings/general") + pushLink(LinkAnnotation.Url("https://login.tailscale.com/admin/settings/general")) withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { append("Settings > General") } @@ -242,19 +238,9 @@ fun OwnerDeleteDialogText(onSettingsClick: () -> Unit) { append(part2b) } - val context = LocalContext.current - ClickableText( + Text( text = annotatedText, - style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), - onClick = { offset -> - annotatedText - .getStringAnnotations(tag = "settings", start = offset, end = offset) - .firstOrNull() - ?.let { annotation -> - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) - context.startActivity(intent) - } - }) + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface)) } @Composable diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt index d14f7911cb..c8a4506fa1 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -124,9 +124,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper { override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { runBlocking { waitUntilTaildropDirReady() } val (uri, stream) = openWriterFD(fileName, offset) - if (stream == null) { - throw IOException("Failed to open file writer for $fileName") - } currentUri[fileName] = uri return OutputStreamAdapter(stream) } @@ -212,12 +209,12 @@ object ShareFileHelper : libtailscale.ShareFileHelper { override fun deleteFile(uri: String) { runBlocking { waitUntilTaildropDirReady() } val ctx = appContext ?: throw IOException("DeleteFile: not initialized") - val uri = Uri.parse(uri) + val parsedUri = Uri.parse(uri) val doc = - DocumentFile.fromSingleUri(ctx, uri) - ?: throw IOException("DeleteFile: cannot resolve URI $uri") + DocumentFile.fromSingleUri(ctx, parsedUri) + ?: throw IOException("DeleteFile: cannot resolve URI $parsedUri") if (!doc.delete()) { - throw IOException("DeleteFile: delete() returned false for $uri") + throw IOException("DeleteFile: delete() returned false for $parsedUri") } } diff --git a/android/src/main/java/com/tailscale/ipn/util/TSLog.kt b/android/src/main/java/com/tailscale/ipn/util/TSLog.kt index c078b064ca..3450e0eaa3 100644 --- a/android/src/main/java/com/tailscale/ipn/util/TSLog.kt +++ b/android/src/main/java/com/tailscale/ipn/util/TSLog.kt @@ -44,7 +44,7 @@ object TSLog { libtailscaleWrapper.sendLog(tag, message) } else { Log.e(tag, message, throwable) - libtailscaleWrapper.sendLog(tag, "$message ${throwable?.localizedMessage}") + libtailscaleWrapper.sendLog(tag, "$message ${throwable.localizedMessage}") } } From 76b307f3e2e5703f6b61f80cd13ee2cb4dff8071 Mon Sep 17 00:00:00 2001 From: Brett Jenkins Date: Tue, 16 Jun 2026 14:25:47 +0100 Subject: [PATCH 2/3] android: add launcher shortcuts to connect and disconnect the VPN Add an AutomationActivity trampoline and two static launcher shortcuts (Connect, Disconnect) wired to the existing CONNECT_VPN / DISCONNECT_VPN intents. The actions can also be invoked from Samsung Modes and Routines or Tasker. The activity exists because starting an activity reliably wakes an app that the OS has force-stopped or deep-slept (e.g. Samsung battery management) and lets connect start the foreground VPN service, whereas the broadcast/Worker path is unreliable from that state. Exit-node automation is unchanged and remains available via the existing IPNReceiver USE_EXIT_NODE broadcast once the VPN is connected. Updates tailscale/tailscale#10831 Updates tailscale/tailscale#14148 Updates tailscale/tailscale#13623 Updates tailscale/tailscale#18847 Updates tailscale/tailscale#9531 Updates tailscale/tailscale#9497 Updates tailscale/tailscale#16415 Updates tailscale/tailscale#17855 Updates tailscale/tailscale#17738 Signed-off-by: Brett Jenkins --- android/src/main/AndroidManifest.xml | 24 +++++++ .../com/tailscale/ipn/AutomationActivity.kt | 63 +++++++++++++++++++ android/src/main/res/values/strings.xml | 6 ++ android/src/main/res/xml/shortcuts.xml | 28 +++++++++ 4 files changed, 121 insertions(+) create mode 100644 android/src/main/java/com/tailscale/ipn/AutomationActivity.kt create mode 100644 android/src/main/res/xml/shortcuts.xml diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 9633e2b2bd..872963a302 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -57,6 +57,30 @@ + + + + + + + + + + + connect() + IPNReceiver.INTENT_DISCONNECT_VPN -> UninitializedApp.get().stopVPN() + else -> TSLog.w(TAG, "unknown action: ${intent?.action}") + } + + // Never show any UI: finish before this activity becomes visible. + finish() + } + + /** + * Starts the VPN directly when it is ready and consent is already granted. Otherwise (not set up, + * or consent not yet granted) opens the app, which handles login and the consent prompt. + */ + private fun connect() { + val app = UninitializedApp.get() + if (app.isAbleToStartVPN() && VpnService.prepare(this) == null) { + app.startVPN() + } else { + launchMainActivity() + } + } + + private fun launchMainActivity() { + val intent = + Intent(this, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + try { + startActivity(intent) + } catch (e: Exception) { + TSLog.e(TAG, "Failed to launch MainActivity: $e") + } + } + + companion object { + private const val TAG = "AutomationActivity" + } +} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 608f1f2a2e..1e3f1fb664 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -378,4 +378,10 @@ Enable hardware attestation Use hardware-backed keys to bind node identity to the device + + Connect + Connect to Tailscale + Disconnect + Disconnect from Tailscale + diff --git a/android/src/main/res/xml/shortcuts.xml b/android/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000000..a4a1a3164f --- /dev/null +++ b/android/src/main/res/xml/shortcuts.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + From 469e51a44c97feb70ad2b8037c9cafb0485f0267 Mon Sep 17 00:00:00 2001 From: Brett Jenkins Date: Wed, 24 Jun 2026 18:23:55 +0100 Subject: [PATCH 3/3] android: lazily init app in localAPI Request to fix cold-start crash A localAPI request issued from a cold-started process (e.g. UseExitNodeWorker, kicked off by IPNReceiver) reads Request's lateinit `app` before the backend has been initialized, crashing the process with UninitializedPropertyAccessException. Resolve the app lazily via App.get() in execute(), matching the lazy init that Client already uses. Signed-off-by: Brett Jenkins --- .../java/com/tailscale/ipn/ui/localapi/Client.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index aeed568aca..b98cbbcf85 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -306,11 +306,24 @@ class Request( fun setApp(newApp: libtailscale.Application) { app = newApp } + + // Returns the libtailscale app, initializing the backend on demand. A localAPI request can be + // issued from a cold-started process (e.g. UseExitNodeWorker, kicked off by IPNReceiver) that + // never went through App.get(), so setApp() may not have run yet. Resolving lazily here mirrors + // Client's own lazy `app` and avoids crashing the process with an + // UninitializedPropertyAccessException. + private fun resolveApp(): libtailscale.Application { + if (!::app.isInitialized) { + app = App.get().getLibtailscaleApp() + } + return app + } } @OptIn(ExperimentalSerializationApi::class) fun execute() { scope.launch(Dispatchers.IO) { + val app = resolveApp() TSLog.d(TAG, "Executing request:${method}:${fullPath} on app $app") try { val resp =