Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
<!-- Static app shortcuts must be attached to the LAUNCHER activity. -->
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>

<!--
Transparent, no-UI trampoline that connects or disconnects the VPN. Exported so the
actions show up in the launcher long-press shortcut menu, Samsung Modes & Routines, and
can be sent from Tasker. Starting an activity (rather than IPNReceiver's broadcast)
reliably wakes a stopped app so connect can start the foreground VPN service.
-->
<activity
android:name="AutomationActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="com.tailscale.ipn.CONNECT_VPN" />
<action android:name="com.tailscale.ipn.DISCONNECT_VPN" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="ShareActivity"
Expand Down
9 changes: 3 additions & 6 deletions android/src/main/java/com/tailscale/ipn/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 { _ ->
Expand Down Expand Up @@ -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))
}
Expand Down
63 changes: 63 additions & 0 deletions android/src/main/java/com/tailscale/ipn/AutomationActivity.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
20 changes: 8 additions & 12 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ class MainActivity : ComponentActivity() {
loginAtUrl = ::login,
navigation = mainViewNav,
viewModel = viewModel,
appViewModel = appViewModel)
)
}
composable("search") {
val autoFocus = viewModel.autoFocusSearch
Expand Down Expand Up @@ -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 } }
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,24 @@ class Request<T>(
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 =
Expand Down
7 changes: 5 additions & 2 deletions android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
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
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
Expand Down Expand Up @@ -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) }
}
})
Expand Down
12 changes: 4 additions & 8 deletions android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -829,5 +828,5 @@ fun MainViewPreview() {
onNavigateToHealth = {},
onNavigateToSearch = {}),
vm,
appViewModel)
)
}
Loading
Loading