Makefile: move NDK_ROOT below ANDROID_HOME detection

If ANDROID_HOME is being detected by the code that finds a valid home
with an empty host environment, then NDK_ROOT should be able to use
that, but it was out of order in the evaluation.

Updates #cleanup

Signed-off-by: James Tucker <james@tailscale.com>
kari/dirselmove
James Tucker 4 months ago committed by kari-ts
parent e71641a422
commit c887c926da

@ -13,19 +13,6 @@
DOCKER_IMAGE := tailscale-android-build-amd64-041425-1 DOCKER_IMAGE := tailscale-android-build-amd64-041425-1
export TS_USE_TOOLCHAIN=1 export TS_USE_TOOLCHAIN=1
# Auto-select an NDK from ANDROID_HOME (choose highest version available)
NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1)
HOST_OS := $(shell uname | tr A-Z a-z)
ifeq ($(HOST_OS),linux)
STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy
else ifeq ($(HOST_OS),darwin)
STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy
endif
$(info Using NDK_ROOT: $(NDK_ROOT))
$(info Using STRIP_TOOL: $(STRIP_TOOL))
DEBUG_APK := tailscale-debug.apk DEBUG_APK := tailscale-debug.apk
RELEASE_AAB := tailscale-release.aab RELEASE_AAB := tailscale-release.aab
RELEASE_TV_AAB := tailscale-tv-release.aab RELEASE_TV_AAB := tailscale-tv-release.aab
@ -64,6 +51,21 @@ ifeq ($(ANDROID_SDK_ROOT),)
endif endif
export ANDROID_HOME ?= $(ANDROID_SDK_ROOT) export ANDROID_HOME ?= $(ANDROID_SDK_ROOT)
# Auto-select an NDK from ANDROID_HOME (choose highest version available)
NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1)
HOST_OS := $(shell uname | tr A-Z a-z)
ifeq ($(HOST_OS),linux)
STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy
else ifeq ($(HOST_OS),darwin)
STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy
endif
$(info Using ANDROID_HOME: $(ANDROID_HOME))
$(info Using NDK_ROOT: $(NDK_ROOT))
$(info Using STRIP_TOOL: $(STRIP_TOOL))
# Attempt to find Android Studio for Linux configuration, which does not have a # Attempt to find Android Studio for Linux configuration, which does not have a
# predetermined location. # predetermined location.
ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1) ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1)

@ -33,8 +33,8 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
@ -211,15 +211,17 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
* Tailscale because directFileRoot must be set before LocalBackend starts being used. * Tailscale because directFileRoot must be set before LocalBackend starts being used.
*/ */
fun startLibtailscale(directFileRoot: String) { fun startLibtailscale(directFileRoot: String) {
ShareFileHelper.init(this, directFileRoot)
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this) app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
ShareFileHelper.init(this, app, directFileRoot, applicationScope)
Request.setApp(app) Request.setApp(app)
Notifier.setApp(app) Notifier.setApp(app)
Notifier.start(applicationScope) Notifier.start(applicationScope)
} }
private fun initViewModels() { private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) appViewModel =
ViewModelProvider(this, AppViewModelFactory(this, ShareFileHelper.observeTaildropPrompt()))
.get(AppViewModel::class.java)
} }
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) { fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
@ -250,11 +252,12 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
override fun getStateStoreKeysJSON(): String { override fun getStateStoreKeysJSON(): String {
val prefix = "statestore-" val prefix = "statestore-"
val keys = getEncryptedPrefs() val keys =
.getAll() getEncryptedPrefs()
.keys .getAll()
.filter { it.startsWith(prefix) } .keys
.map { it.removePrefix(prefix) } .filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
return org.json.JSONArray(keys).toString() return org.json.JSONArray(keys).toString()
} }
@ -400,7 +403,7 @@ open class UninitializedApp : Application() {
private lateinit var appInstance: UninitializedApp private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel lateinit var appViewModel: AppViewModel
@JvmStatic @JvmStatic
fun get(): UninitializedApp { fun get(): UninitializedApp {
@ -587,8 +590,8 @@ open class UninitializedApp : Application() {
return builtInDisallowedPackageNames + userDisallowed return builtInDisallowedPackageNames + userDisallowed
} }
fun getAppScopedViewModel(): VpnViewModel { fun getAppScopedViewModel(): AppViewModel {
return vpnViewModel return appViewModel
} }
val builtInDisallowedPackageNames: List<String> = val builtInDisallowedPackageNames: List<String> =

@ -34,13 +34,20 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
@ -75,39 +82,43 @@ import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.NotificationsView import com.tailscale.ipn.ui.view.NotificationsView
import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.PrimaryActionButton
import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.SubnetRoutingView import com.tailscale.ipn.ui.view.SubnetRoutingView
import com.tailscale.ipn.ui.view.TaildropDirView import com.tailscale.ipn.ui.view.TaildropDirView
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.TailnetLockSetupView
import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import libtailscale.Libtailscale
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent> private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy { private val appViewModel: AppViewModel by viewModels {
val app = App.get() AppViewModelFactory(
vpnViewModel = app.getAppScopedViewModel() application = this.application, taildropPrompt = ShareFileHelper.taildropPrompt)
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
} }
private lateinit var vpnViewModel: VpnViewModel
private val viewModel: MainViewModel by viewModels { MainViewModelFactory(appViewModel) }
val permissionsViewModel: PermissionsViewModel by viewModels() val permissionsViewModel: PermissionsViewModel by viewModels()
companion object { companion object {
@ -132,7 +143,6 @@ class MainActivity : ComponentActivity() {
// grab app to make sure it initializes // grab app to make sure it initializes
App.get() App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(App.get(), rm) MDMSettings.update(App.get(), rm)
@ -154,7 +164,7 @@ class MainActivity : ComponentActivity() {
registerForActivityResult(VpnPermissionContract()) { granted -> registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) { if (granted) {
TSLog.d("VpnPermission", "VPN permission granted") TSLog.d("VpnPermission", "VPN permission granted")
vpnViewModel.setVpnPrepared(true) appViewModel.setVpnPrepared(true)
App.get().startVPN() App.get().startVPN()
} else { } else {
if (isAnotherVpnActive(this)) { if (isAnotherVpnActive(this)) {
@ -162,7 +172,7 @@ class MainActivity : ComponentActivity() {
showOtherVPNConflictDialog() showOtherVPNConflictDialog()
} else { } else {
TSLog.d("VpnPermission", "Permission was denied by the user") TSLog.d("VpnPermission", "Permission was denied by the user")
vpnViewModel.setVpnPrepared(false) appViewModel.setVpnPrepared(false)
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle(R.string.vpn_permission_needed) .setTitle(R.string.vpn_permission_needed)
@ -198,9 +208,10 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
Libtailscale.setDirectFileRoot(uri.toString())
TaildropDirectoryStore.saveFileDirectory(uri) TaildropDirectoryStore.saveFileDirectory(uri)
permissionsViewModel.refreshCurrentDir() permissionsViewModel.refreshCurrentDir()
ShareFileHelper.notifyDirectoryReady()
ShareFileHelper.setUri(uri.toString())
} catch (e: Exception) { } catch (e: Exception) {
TSLog.e("MainActivity", "Failed to set Taildrop root: $e") TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
} }
@ -219,9 +230,38 @@ class MainActivity : ComponentActivity() {
} }
} }
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher) appViewModel.directoryPickerLauncher = directoryPickerLauncher
setContent { setContent {
var showDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
appViewModel.showDirectoryPickerInterstitial.collect { showDialog = true }
}
if (showDialog) {
AppTheme {
AlertDialog(
onDismissRequest = {
showDialog = false
appViewModel.directoryPickerLauncher?.launch(null)
},
title = {
Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
},
text = { TaildropDirectoryPickerPrompt() },
confirmButton = {
PrimaryActionButton(
onClick = {
showDialog = false
appViewModel.directoryPickerLauncher?.launch(null)
}) {
Text(text = stringResource(id = R.string.taildrop_directory_picker_button))
}
})
}
}
navController = rememberNavController() navController = rememberNavController()
AppTheme { AppTheme {
@ -308,7 +348,11 @@ class MainActivity : ComponentActivity() {
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") }) onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel) MainView(
loginAtUrl = ::login,
navigation = mainViewNav,
viewModel = viewModel,
appViewModel = appViewModel)
} }
composable("search") { composable("search") {
val autoFocus = viewModel.autoFocusSearch val autoFocus = viewModel.autoFocusSearch
@ -318,7 +362,9 @@ class MainActivity : ComponentActivity() {
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
autoFocus = autoFocus) autoFocus = autoFocus)
} }
composable("settings") { SettingsView(settingsNav) } composable("settings") {
SettingsView(settingsNav = settingsNav, appViewModel = appViewModel)
}
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) } composable("health") { HealthView(backTo("main")) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }

@ -32,7 +32,6 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@ -71,13 +70,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide import com.tailscale.ipn.mdm.ShowHide
@ -87,7 +84,6 @@ import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.theme.customErrorContainer import com.tailscale.ipn.ui.theme.customErrorContainer
import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.errorButton import com.tailscale.ipn.ui.theme.errorButton
@ -109,9 +105,9 @@ import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
// Navigation actions for the MainView // Navigation actions for the MainView
@ -129,9 +125,17 @@ fun MainView(
loginAtUrl: (String) -> Unit, loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation, navigation: MainViewNavigation,
viewModel: MainViewModel, viewModel: MainViewModel,
appViewModel: AppViewModel
) { ) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthIcon by viewModel.healthIcon.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState()
val showDirectoryPicker = appViewModel.showDirectoryPickerInterstitial.collectAsState(null)
LaunchedEffect(showDirectoryPicker.value) {
if (showDirectoryPicker.value != null) {
appViewModel.directoryPickerLauncher?.launch(null)
}
}
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
@ -151,8 +155,6 @@ fun MainView(
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
val showDirectoryPickerInterstitial by
viewModel.showDirectoryPickerInterstitial.collectAsState()
// Hide the header only on Android TV when the user needs to login // Hide the header only on Android TV when the user needs to login
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
@ -222,7 +224,7 @@ fun MainView(
if (!viewModel.skipPromptsForAuthKeyLogin()) { if (!viewModel.skipPromptsForAuthKeyLogin()) {
LaunchedEffect(state) { LaunchedEffect(state) {
if (state == Ipn.State.Running && !isAndroidTV()) { if (state == Ipn.State.Running && !isAndroidTV()) {
viewModel.checkIfTaildropDirectorySelected() appViewModel.checkIfTaildropDirectorySelected()
} }
} }
} }
@ -259,25 +261,6 @@ fun MainView(
{ viewModel.showVPNPermissionLauncherIfUnauthorized() }) { viewModel.showVPNPermissionLauncherIfUnauthorized() })
} }
} }
showDirectoryPickerInterstitial.let { show ->
if (show) {
AppTheme {
AlertDialog(
onDismissRequest = { viewModel.showDirectoryPickerLauncher() },
title = {
Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
},
text = { TaildropDirectoryPickerPrompt() },
confirmButton = {
PrimaryActionButton(onClick = { viewModel.showDirectoryPickerLauncher() }) {
Text(
text = stringResource(id = R.string.taildrop_directory_picker_button))
}
})
}
}
}
} }
currentPingDevice?.let { _ -> currentPingDevice?.let { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
@ -865,12 +848,12 @@ fun Search(
} }
} }
} }
/*
@Preview @Preview
@Composable @Composable
fun MainViewPreview() { fun MainViewPreview() {
val vpnViewModel = VpnViewModel(App.get()) val appViewModel = AppViewModel(App.get())
val vm = MainViewModel(vpnViewModel) val vm = MainViewModel(appViewModel)
MainView( MainView(
{}, {},
@ -880,5 +863,7 @@ fun MainViewPreview() {
onNavigateToExitNodes = {}, onNavigateToExitNodes = {},
onNavigateToHealth = {}, onNavigateToHealth = {},
onNavigateToSearch = {}), onNavigateToSearch = {}),
vm) vm,
appViewModel)
} }
*/

@ -38,15 +38,15 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AppVersion import com.tailscale.ipn.ui.util.AppVersion
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
@Composable @Composable
fun SettingsView( fun SettingsView(
settingsNav: SettingsNav, settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(), viewModel: SettingsViewModel = viewModel(),
vpnViewModel: VpnViewModel = viewModel() appViewModel: AppViewModel = viewModel()
) { ) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
@ -55,7 +55,7 @@ fun SettingsView(
val managedByOrganization by viewModel.managedByOrganization.collectAsState() val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState() val tailnetLockEnabled by viewModel.tailNetLockEnabled.collectAsState()
val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState() val corpDNSEnabled by viewModel.corpDNSEnabled.collectAsState()
val isVPNPrepared by vpnViewModel.vpnPrepared.collectAsState() val isVPNPrepared by appViewModel.vpnPrepared.collectAsState()
val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState() val showTailnetLock by MDMSettings.manageTailnetLock.flow.collectAsState()
val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState() val useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()

@ -0,0 +1,115 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.app.Application
import android.net.Uri
import android.net.VpnService
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow<Unit>) :
ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(AppViewModel::class.java)) {
return AppViewModel(application, taildropPrompt) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
// Application context-aware ViewModel used to track app-wide VPN and Taildrop state.
// This must be application-scoped because Tailscale may be enabled, disabled, or used for
// file transfers (Taildrop) outside the activity lifecycle.
//
// Responsibilities:
// - Track VPN preparation state (e.g., whether permission has been granted) and activity state
// - Monitor incoming Taildrop file transfers
// - Coordinate prompts for Taildrop directory selection if not yet configured
class AppViewModel(application: Application, private val taildropPrompt: Flow<Unit>) :
AndroidViewModel(application) {
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
// if the user has previously consented to the VPN application. This is used to determine whether
// a VPN permission launcher needs to be shown.
val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// Whether a VPN interface has been established. This is set by net.updateTUN upon
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
val _vpnActive = MutableStateFlow(false)
val vpnActive: StateFlow<Boolean> = _vpnActive
// Select Taildrop directory
var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
private val _showDirectoryPickerInterstitial = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val showDirectoryPickerInterstitial: SharedFlow<Unit> = _showDirectoryPickerInterstitial
val TAG = "AppViewModel"
init {
observeIncomingTaildrop()
prepareVpn()
}
private fun observeIncomingTaildrop() {
viewModelScope.launch {
taildropPrompt.collect {
TSLog.d(TAG, "Taildrop event received, checking directory")
checkIfTaildropDirectorySelected()
}
}
}
private fun prepareVpn() {
// Check if the user has granted permission yet.
if (!vpnPrepared.value) {
val vpnIntent = VpnService.prepare(getApplication())
if (vpnIntent != null) {
setVpnPrepared(false)
Log.d(TAG, "VpnService.prepare returned non-null intent")
} else {
setVpnPrepared(true)
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
}
}
}
fun checkIfTaildropDirectorySelected() {
val app = App.get()
val storedUri = app.getStoredDirectoryUri()
if (ShareFileHelper.hasValidTaildropDir()) {
return
}
val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) }
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
TSLog.d(
"MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.")
viewModelScope.launch { _showDirectoryPickerInterstitial.tryEmit(Unit) }
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun setVpnActive(isActive: Boolean) {
_vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}

@ -39,18 +39,18 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration import java.time.Duration
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) { if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T return MainViewModel(appViewModel) as T
} }
throw IllegalArgumentException("Unknown ViewModel class") throw IllegalArgumentException("Unknown ViewModel class")
} }
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
@ -98,9 +98,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
var pingViewModel: PingViewModel = PingViewModel() var pingViewModel: PingViewModel = PingViewModel()
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared val isVpnPrepared: StateFlow<Boolean> = appViewModel.vpnPrepared
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive val isVpnActive: StateFlow<Boolean> = appViewModel.vpnActive
var searchJob: Job? = null var searchJob: Job? = null
@ -215,41 +215,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
if (vpnIntent != null) { if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent) vpnPermissionLauncher?.launch(vpnIntent)
} else { } else {
vpnViewModel.setVpnPrepared(true) appViewModel.setVpnPrepared(true)
startVPN() startVPN()
} }
_requestVpnPermission.value = false // reset _requestVpnPermission.value = false // reset
} }
fun showDirectoryPickerLauncher() {
_showDirectoryPickerInterstitial.set(false)
directoryPickerLauncher?.launch(null)
}
fun checkIfTaildropDirectorySelected() {
if (skipPromptsForAuthKeyLogin() || AndroidTVUtil.isAndroidTV()) {
return
}
val app = App.get()
val storedUri = app.getStoredDirectoryUri()
if (storedUri == null) {
// No stored URI, so launch the directory picker.
_showDirectoryPickerInterstitial.set(true)
return
}
val documentFile = DocumentFile.fromTreeUri(app, storedUri)
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
TSLog.d(
"MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.")
_showDirectoryPickerInterstitial.set(true)
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun toggleVpn(desiredState: Boolean) { fun toggleVpn(desiredState: Boolean) {
if (isToggleInProgress.value) { if (isToggleInProgress.value) {
// Prevent toggling while a previous toggle is in progress // Prevent toggling while a previous toggle is in progress
@ -257,16 +228,13 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
checkIfTaildropDirectorySelected()
isToggleInProgress.value = true isToggleInProgress.value = true
try { try {
val currentState = Notifier.state.value val currentState = Notifier.state.value
val isPrepared = vpnViewModel.vpnPrepared.value
if (desiredState) { if (desiredState) {
// User wants to turn ON the VPN // User wants to turn ON the VPN
when { when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
currentState != Ipn.State.Running -> startVPN() currentState != Ipn.State.Running -> startVPN()
} }
} else { } else {
@ -297,10 +265,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// No intent means we're already authorized // No intent means we're already authorized
vpnPermissionLauncher = launcher vpnPermissionLauncher = launcher
} }
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
directoryPickerLauncher = launcher
}
} }
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {

@ -1,65 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.app.Application
import android.net.VpnService
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class VpnViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(VpnViewModel::class.java)) {
return VpnViewModel(application) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
// Application context aware view model that tracks whether the VPN has been prepared. This must be
// application scoped because Tailscale might be toggled on and off outside of the activity
// lifecycle.
class VpnViewModel(application: Application) : AndroidViewModel(application) {
// Whether the VPN is prepared. This is set to true if the VPN application is already prepared, or
// if the user has previously consented to the VPN application. This is used to determine whether
// a VPN permission launcher needs to be shown.
val _vpnPrepared = MutableStateFlow(false)
val vpnPrepared: StateFlow<Boolean> = _vpnPrepared
// Whether a VPN interface has been established. This is set by net.updateTUN upon
// VpnServiceBuilder.establish, and consumed by UI to reflect VPN state.
val _vpnActive = MutableStateFlow(false)
val vpnActive: StateFlow<Boolean> = _vpnActive
val TAG = "VpnViewModel"
init {
prepareVpn()
}
private fun prepareVpn() {
// Check if the user has granted permission yet.
if (!vpnPrepared.value) {
val vpnIntent = VpnService.prepare(getApplication())
if (vpnIntent != null) {
setVpnPrepared(false)
Log.d(TAG, "VpnService.prepare returned non-null intent")
} else {
setVpnPrepared(true)
Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared")
}
}
}
fun setVpnActive(isActive: Boolean) {
_vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}

@ -8,8 +8,15 @@ import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.tailscale.ipn.TaildropDirectoryStore
import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.InputStreamAdapter
import com.tailscale.ipn.ui.util.OutputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import libtailscale.Libtailscale import libtailscale.Libtailscale
import org.json.JSONObject import org.json.JSONObject
import java.io.FileOutputStream import java.io.FileOutputStream
@ -22,18 +29,64 @@ data class SafFile(val fd: Int, val uri: String)
object ShareFileHelper : libtailscale.ShareFileHelper { object ShareFileHelper : libtailscale.ShareFileHelper {
private var appContext: Context? = null private var appContext: Context? = null
private var app: libtailscale.Application? = null
private var savedUri: String? = null private var savedUri: String? = null
private var scope: CoroutineScope? = null
@JvmStatic @JvmStatic
fun init(context: Context, uri: String) { fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) {
appContext = context.applicationContext appContext = context.applicationContext
this.app = app
savedUri = uri savedUri = uri
scope = appScope
Libtailscale.setShareFileHelper(this) Libtailscale.setShareFileHelper(this)
TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri")
} }
// A simple data class that holds a SAF OutputStream along with its URI. // A simple data class that holds a SAF OutputStream along with its URI.
data class SafStream(val uri: String, val stream: OutputStream) data class SafStream(val uri: String, val stream: OutputStream)
val taildropPrompt = MutableSharedFlow<Unit>(replay = 1)
fun observeTaildropPrompt(): Flow<Unit> = taildropPrompt
@Volatile private var directoryReady: CompletableDeferred<Unit>? = null
fun hasValidTaildropDir(): Boolean {
val uri = TaildropDirectoryStore.loadSavedDir()
if (uri == null) return false
// Only SAF tree URIs are supported
if (uri.scheme != "content") {
TSLog.w("ShareFileHelper", "Invalid URI scheme for taildrop dir: ${uri.scheme}")
return false
}
val context = appContext ?: return false
val docFile = DocumentFile.fromTreeUri(context, uri)
if (docFile == null || !docFile.exists() || !docFile.canWrite()) {
TSLog.w("ShareFileHelper", "Stored taildrop URI is invalid or inaccessible: $uri")
return false
}
return true
}
private suspend fun waitUntilTaildropDirReady() {
if (!hasValidTaildropDir()) {
if (directoryReady?.isActive != true) {
directoryReady = CompletableDeferred()
scope?.launch { taildropPrompt.emit(Unit) }
}
directoryReady?.await()
}
}
fun notifyDirectoryReady() {
directoryReady?.takeIf { !it.isCompleted }?.complete(Unit)
}
// A helper function that opens or creates a SafStream for a given file. // A helper function that opens or creates a SafStream for a given file.
private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> { private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> {
val context = appContext ?: return "" to null val context = appContext ?: return "" to null
@ -74,6 +127,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class) @Throws(IOException::class)
override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream {
runBlocking { waitUntilTaildropDirReady() }
val (uri, stream) = openWriterFD(fileName, offset) val (uri, stream) = openWriterFD(fileName, offset)
if (stream == null) { if (stream == null) {
throw IOException("Failed to open file writer for $fileName") throw IOException("Failed to open file writer for $fileName")
@ -84,6 +138,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class) @Throws(IOException::class)
override fun getFileURI(fileName: String): String { override fun getFileURI(fileName: String): String {
runBlocking { waitUntilTaildropDirReady() }
currentUri[fileName]?.let { currentUri[fileName]?.let {
return it return it
} }
@ -125,8 +180,8 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
return newUri.toString() return newUri.toString()
} }
} catch (e: Exception) { } catch (e: Exception) {
TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") TSLog.w(
"renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}")
} }
val dest = val dest =
@ -249,6 +304,10 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
return InputStreamAdapter(inStream) return InputStreamAdapter(inStream)
} }
fun setUri(uri: String) {
savedUri = uri
}
private class SeekableOutputStream( private class SeekableOutputStream(
private val fos: FileOutputStream, private val fos: FileOutputStream,
private val pfd: ParcelFileDescriptor private val pfd: ParcelFileDescriptor

@ -114,23 +114,6 @@ type backend struct {
type settingsFunc func(*router.Config, *dns.OSConfig) error type settingsFunc func(*router.Config, *dns.OSConfig) error
func (a *App) runBackend(ctx context.Context) error { func (a *App) runBackend(ctx context.Context) error {
for {
err := a.runBackendOnce(ctx)
if err != nil {
log.Printf("runBackendOnce error: %v", err)
}
// Wait for a restart trigger
<-a.backendRestartCh
}
}
func (a *App) runBackendOnce(ctx context.Context) error {
select {
case <-a.backendRestartCh:
default:
}
paths.AppSharedDir.Store(a.dataDir) paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetOSVersion(a.osVersion())
hostinfo.SetPackage(a.appCtx.GetInstallSource()) hostinfo.SetPackage(a.appCtx.GetInstallSource())
@ -338,7 +321,6 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
ext.SetFileOps(newAndroidFileOps(a.shareFileHelper)) ext.SetFileOps(newAndroidFileOps(a.shareFileHelper))
ext.SetDirectFileRoot(a.directFileRoot)
} }
if err != nil { if err != nil {
@ -368,14 +350,9 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
func (a *App) watchFileOpsChanges() { func (a *App) watchFileOpsChanges() {
for { for {
select { select {
case newPath := <-onFilePath:
log.Printf("Got new directFileRoot")
a.directFileRoot = newPath
a.backendRestartCh <- struct{}{}
case helper := <-onShareFileHelper: case helper := <-onShareFileHelper:
log.Printf("Got shareFIleHelper") log.Printf("Got ShareFileHelper")
a.shareFileHelper = helper a.shareFileHelper = helper
a.backendRestartCh <- struct{}{}
} }
} }
} }

@ -26,9 +26,6 @@ var (
// onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework // onShareFileHelper receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework
onShareFileHelper = make(chan ShareFileHelper, 1) onShareFileHelper = make(chan ShareFileHelper, 1)
// onFilePath receives the SAF path used for Taildrop
onFilePath = make(chan string)
) )
// ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available. // ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available.

@ -243,7 +243,3 @@ func SetShareFileHelper(fileHelper ShareFileHelper) {
onShareFileHelper <- fileHelper onShareFileHelper <- fileHelper
} }
} }
func SetDirectFileRoot(filePath string) {
onFilePath <- filePath
}

Loading…
Cancel
Save