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 @@
+
+
+
+
+
+
+
+
+
+
+
+