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 @@ + + + + + + + + + + + @@ -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/AutomationActivity.kt b/android/src/main/java/com/tailscale/ipn/AutomationActivity.kt new file mode 100644 index 0000000000..043d0355e3 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/AutomationActivity.kt @@ -0,0 +1,63 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +package com.tailscale.ipn + +import android.app.Activity +import android.content.Intent +import android.net.VpnService +import android.os.Bundle +import com.tailscale.ipn.util.TSLog + +/** + * AutomationActivity is a transparent, no-UI trampoline that connects or disconnects the VPN and + * finishes immediately. It backs the launcher long-press shortcuts and can also be invoked from + * Samsung Modes and Routines or Tasker. + * + * It is an activity rather than an extension of [IPNReceiver]'s broadcasts because starting an + * activity reliably wakes an app that the OS has force-stopped or put into deep sleep, e.g. under + * Samsung battery management. The brief foreground also lets connect start the VPN service without + * hitting the Android 12+ restriction on starting a foreground service from the background, which + * the broadcast path can trip when the app has been idle. + */ +class AutomationActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + when (intent?.action) { + IPNReceiver.INTENT_CONNECT_VPN -> 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/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/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 = 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}") } } 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 @@ + + + + + + + + + + + +