android: defer taildrop selector until first taildrop attempt (#684)

Move Taildrop directory selector out of onboarding
-Listen for Taildrop, and show selector if a directory has not been set

Remove LocalBackend re-initialization
-This is no longer necessary since the directory is set in FileOps

Updates tailscale/corp#29211

Signed-off-by: kari-ts <kari@tailscale.com>
(cherry picked from commit e68e64014e)
pull/686/head
kari-ts 4 months ago committed by Jonathan Nobels
parent 7071d41ba7
commit ab953cf379

@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
import android.Manifest
import android.app.Application
import android.app.Notification
@ -33,8 +32,8 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog
@ -53,17 +52,14 @@ import java.io.IOException
import java.net.NetworkInterface
import java.security.GeneralSecurityException
import java.util.Locale
class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
companion object {
private const val FILE_CHANNEL_ID = "tailscale-files"
// Key to store the SAF URI in EncryptedSharedPreferences.
private val PREF_KEY_SAF_URI = "saf_directory_uri"
private const val TAG = "App"
private lateinit var appInstance: App
/**
* Initializes the app (if necessary) and returns the singleton app instance. Always use this
* function to obtain an App reference to make sure the app initializes.
@ -74,45 +70,33 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
return appInstance
}
}
val dns = DnsConfig()
private lateinit var connectivityManager: ConnectivityManager
private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver
private lateinit var app: libtailscale.Application
override val viewModelStore: ViewModelStore
get() = appViewModelStore
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
var healthNotifier: HealthNotifier? = null
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this)
override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK
override fun log(s: String, s1: String) {
Log.d(s, s1)
}
fun getLibtailscaleApp(): libtailscale.Application {
if (!isInitialized) {
initOnce() // Calls the synchronized initialization logic
}
return app
}
override fun onCreate() {
super.onCreate()
appInstance = this
setUnprotectedInstance(this)
mdmChangeReceiver = MDMSettingsChangedReceiver()
val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)
registerReceiver(mdmChangeReceiver, filter)
createNotificationChannel(
STATUS_CHANNEL_ID,
getString(R.string.vpn_status),
@ -129,7 +113,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
getString(R.string.health_channel_description),
NotificationManagerCompat.IMPORTANCE_HIGH)
}
override fun onTerminate() {
super.onTerminate()
Notifier.stop()
@ -138,19 +121,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
viewModelStore.clear()
unregisterReceiver(mdmChangeReceiver)
}
@Volatile private var isInitialized = false
@Synchronized
private fun initOnce() {
if (isInitialized) {
return
}
initializeApp()
isInitialized = true
}
private fun initializeApp() {
// Check if a directory URI has already been stored.
val storedUri = getStoredDirectoryUri()
@ -166,7 +145,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
applicationScope.launch {
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(get(), rm)
Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) {
state,
@ -184,11 +162,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
if (state == Ipn.State.Stopped) {
notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value)
}
val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running
updateConnStatus(ableToStartVPN)
QuickToggleService.setVPNRunning(vpnRunning)
// Update notification status when VPN is running
if (vpnRunning) {
notifyStatus(
@ -205,21 +181,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
TSLog.init(this)
FeatureFlags.initialize(mapOf("enable_new_search" to true))
}
/**
* Called when a SAF directory URI is available (either already stored or chosen). We must restart
* Tailscale because directFileRoot must be set before LocalBackend starts being used.
*/
fun startLibtailscale(directFileRoot: String) {
ShareFileHelper.init(this, directFileRoot)
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
ShareFileHelper.init(this, app, directFileRoot, applicationScope)
Request.setApp(app)
Notifier.setApp(app)
Notifier.start(applicationScope)
}
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) {
@ -233,14 +210,12 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
Client(applicationScope)
.editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback)
}
// encryptToPref a byte array of data using the Jetpack Security
// library and writes it to a global encrypted preference store.
@Throws(IOException::class, GeneralSecurityException::class)
override fun encryptToPref(prefKey: String?, plaintext: String?) {
getEncryptedPrefs().edit().putString(prefKey, plaintext).commit()
}
// decryptFromPref decrypts a encrypted preference using the Jetpack Security
// library and returns the plaintext.
@Throws(IOException::class, GeneralSecurityException::class)
@ -250,18 +225,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
override fun getStateStoreKeysJSON(): String {
val prefix = "statestore-"
val keys = getEncryptedPrefs()
.getAll()
.keys
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
val keys =
getEncryptedPrefs()
.getAll()
.keys
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
return org.json.JSONArray(keys).toString()
}
@Throws(IOException::class, GeneralSecurityException::class)
fun getEncryptedPrefs(): SharedPreferences {
val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create(
this,
"secret_shared_prefs",
@ -269,12 +244,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
}
fun getStoredDirectoryUri(): Uri? {
val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null)
return uriString?.let { Uri.parse(it) }
}
/*
* setAbleToStartVPN remembers whether or not we're able to start the VPN
* by storing this in a shared preference. This allows us to check this
@ -285,7 +258,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
QuickToggleService.updateTile()
TSLog.d("App", "Set Tile Ready: $ableToStartVPN")
}
override fun getModelName(): String {
val manu = Build.MANUFACTURER
var model = Build.MODEL
@ -296,17 +268,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}
return "$manu $model"
}
override fun getOSVersion(): String = Build.VERSION.RELEASE
override fun isChromeOS(): Boolean {
return packageManager.hasSystemFeature("android.hardware.type.pc")
}
override fun getInterfacesAsString(): String {
val interfaces: ArrayList<NetworkInterface> =
java.util.Collections.list(NetworkInterface.getNetworkInterfaces())
val sb = StringBuilder()
for (nif in interfaces) {
try {
@ -322,7 +290,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
nif.isLoopback,
nif.isPointToPoint,
nif.supportsMulticast()))
for (ia in nif.interfaceAddresses) {
val parts = ia.toString().split("/", limit = 0)
if (parts.size > 1) {
@ -334,16 +301,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}
sb.append("\n")
}
return sb.toString()
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyBooleanValue(key: String): Boolean {
return getSyspolicyStringValue(key) == "true"
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringValue(key: String): String {
@ -353,7 +317,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
}
return setting.value?.toString() ?: ""
}
@Throws(
IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class)
override fun getSyspolicyStringArrayJSONValue(key: String): String {
@ -369,12 +332,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
throw MDMSettings.NoSuchKeyException()
}
}
fun notifyPolicyChanged() {
app.notifyPolicyChanged()
}
}
/**
* UninitializedApp contains all of the methods of App that can be used without having to initialize
* the Go backend. This is useful when you want to access functions on the App without creating side
@ -383,30 +344,24 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
open class UninitializedApp : Application() {
companion object {
const val TAG = "UninitializedApp"
const val STATUS_NOTIFICATION_ID = 1
const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2
const val STATUS_CHANNEL_ID = "tailscale-status"
// Key for shared preference that tracks whether or not we're able to start
// the VPN (i.e. we're logged in and machine is authorized).
private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN"
private const val DISALLOWED_APPS_KEY = "disallowedApps"
// File for shared preferences that are not encrypted.
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel
lateinit var appViewModel: AppViewModel
@JvmStatic
fun get(): UninitializedApp {
return appInstance
}
/**
* Return the name of the active (but not the selected/prior one) exit node based on the
* provided [Ipn.Prefs] and [Netmap.NetworkMap].
@ -419,24 +374,19 @@ open class UninitializedApp : Application() {
}
}
}
protected fun setUnprotectedInstance(instance: UninitializedApp) {
appInstance = instance
}
protected fun setAbleToStartVPN(rdy: Boolean) {
getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply()
}
/** This function can be called without initializing the App. */
fun isAbleToStartVPN(): Boolean {
return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false)
}
private fun getUnencryptedPrefs(): SharedPreferences {
return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE)
}
fun startVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN }
// FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will
@ -449,7 +399,6 @@ open class UninitializedApp : Application() {
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+
)
try {
pendingIntent.send()
} catch (foregroundServiceStartException: IllegalStateException) {
@ -462,7 +411,6 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "startVPN hit exception: $e")
}
}
fun stopVPN() {
val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN }
try {
@ -473,7 +421,6 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "stopVPN hit exception in startService(): $e")
}
}
fun restartVPN() {
val intent =
Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN }
@ -485,14 +432,12 @@ open class UninitializedApp : Application() {
TSLog.e(TAG, "restartVPN hit exception in startService(): $e")
}
}
fun createNotificationChannel(id: String, name: String, description: String, importance: Int) {
val channel = NotificationChannel(id, name, importance)
channel.description = description
notificationManager = NotificationManagerCompat.from(this)
notificationManager.createNotificationChannel(channel)
}
fun notifyStatus(
vpnRunning: Boolean,
hideDisconnectAction: Boolean,
@ -500,7 +445,6 @@ open class UninitializedApp : Application() {
) {
notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName))
}
fun notifyStatus(notification: Notification) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
@ -515,7 +459,6 @@ open class UninitializedApp : Application() {
}
notificationManager.notify(STATUS_NOTIFICATION_ID, notification)
}
fun buildStatusNotification(
vpnRunning: Boolean,
hideDisconnectAction: Boolean,
@ -537,7 +480,6 @@ open class UninitializedApp : Application() {
0,
buttonIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
@ -545,7 +487,6 @@ open class UninitializedApp : Application() {
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder =
NotificationCompat.Builder(this, STATUS_CHANNEL_ID)
.setSmallIcon(icon)
@ -563,18 +504,14 @@ open class UninitializedApp : Application() {
}
return builder.build()
}
fun updateUserDisallowedPackageNames(packageNames: List<String>) {
if (packageNames.any { it.isEmpty() }) {
TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)")
return
}
getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply()
this.restartVPN()
}
fun disallowedPackageNames(): List<String> {
val mdmDisallowed =
MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList()
@ -587,8 +524,8 @@ open class UninitializedApp : Application() {
return builtInDisallowedPackageNames + userDisallowed
}
fun getAppScopedViewModel(): VpnViewModel {
return vpnViewModel
fun getAppScopedViewModel(): AppViewModel {
return appViewModel
}
val builtInDisallowedPackageNames: List<String> =
@ -616,4 +553,4 @@ open class UninitializedApp : Application() {
// Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128
"com.google.android.apps.scone",
)
}
}

@ -34,10 +34,18 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
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.res.stringResource
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModelProvider
@ -75,39 +83,38 @@ import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.NotificationsView
import com.tailscale.ipn.ui.view.PeerDetails
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.SearchView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.SubnetRoutingView
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.UserSwitcherNav
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModelFactory
import com.tailscale.ipn.ui.viewModel.PermissionsViewModel
import com.tailscale.ipn.ui.viewModel.PingViewModel
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import libtailscale.Libtailscale
class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
private val viewModel: MainViewModel by lazy {
val app = App.get()
vpnViewModel = app.getAppScopedViewModel()
ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java)
}
private lateinit var vpnViewModel: VpnViewModel
private lateinit var appViewModel: AppViewModel
private lateinit var viewModel: MainViewModel
val permissionsViewModel: PermissionsViewModel by viewModels()
companion object {
@ -119,7 +126,6 @@ class MainActivity : ComponentActivity() {
return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >=
SCREENLAYOUT_SIZE_LARGE
}
// The loginQRCode is used to track whether or not we should be rendering a QR code
// to the user. This is used only on TV platforms with no browser in lieu of
// simply opening the URL. This should be consumed once it has been handled.
@ -132,29 +138,27 @@ class MainActivity : ComponentActivity() {
// grab app to make sure it initializes
App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
appViewModel = (application as App).getAppScopedViewModel()
viewModel =
ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java)
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(App.get(), rm)
if (MDMSettings.onboardingFlow.flow.value.value == ShowHide.Hide ||
MDMSettings.authKey.flow.value.value != null) {
setIntroScreenViewed(true)
}
// (jonathan) TODO: Force the app to be portrait on small screens until we have
// proper landscape layout support
if (!isLandscapeCapable()) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
installSplashScreen()
vpnPermissionLauncher =
registerForActivityResult(VpnPermissionContract()) { granted ->
if (granted) {
TSLog.d("VpnPermission", "VPN permission granted")
vpnViewModel.setVpnPrepared(true)
appViewModel.setVpnPrepared(true)
App.get().startVPN()
} else {
if (isAnotherVpnActive(this)) {
@ -162,7 +166,7 @@ class MainActivity : ComponentActivity() {
showOtherVPNConflictDialog()
} else {
TSLog.d("VpnPermission", "Permission was denied by the user")
vpnViewModel.setVpnPrepared(false)
appViewModel.setVpnPrepared(false)
AlertDialog.Builder(this)
.setTitle(R.string.vpn_permission_needed)
@ -176,7 +180,6 @@ class MainActivity : ComponentActivity() {
}
}
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
val directoryPickerLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
if (uri != null) {
@ -188,7 +191,6 @@ class MainActivity : ComponentActivity() {
} catch (e: SecurityException) {
TSLog.e("MainActivity", "Failed to persist permissions: $e")
}
// Check if write permission is actually granted.
val writePermission =
this.checkUriPermission(
@ -198,9 +200,10 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
Libtailscale.setDirectFileRoot(uri.toString())
TaildropDirectoryStore.saveFileDirectory(uri)
permissionsViewModel.refreshCurrentDir()
ShareFileHelper.notifyDirectoryReady()
ShareFileHelper.setUri(uri.toString())
} catch (e: Exception) {
TSLog.e("MainActivity", "Failed to set Taildrop root: $e")
}
@ -214,14 +217,40 @@ class MainActivity : ComponentActivity() {
} else {
TSLog.d(
"MainActivity", "Taildrop directory not saved. Will fall back to internal storage.")
// Fall back to internal storage.
}
}
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
appViewModel.directoryPickerLauncher = directoryPickerLauncher
setContent {
var showDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.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()
AppTheme {
@ -257,7 +286,6 @@ class MainActivity : ComponentActivity() {
fun backTo(route: String): () -> Unit = {
navController.popBackStack(route = route, inclusive = false)
}
val mainViewNav =
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
@ -270,7 +298,6 @@ class MainActivity : ComponentActivity() {
viewModel.enableSearchAutoFocus()
navController.navigate("search")
})
val settingsNav =
SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
@ -285,7 +312,6 @@ class MainActivity : ComponentActivity() {
onNavigateToPermissions = { navController.navigate("permissions") },
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
ExitNodePickerNav(
onNavigateBackHome = {
@ -297,7 +323,6 @@ class MainActivity : ComponentActivity() {
onNavigateBackToMullvad = backTo("mullvad"),
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
val userSwitcherNav =
UserSwitcherNav(
backToSettings = backTo("settings"),
@ -308,7 +333,11 @@ class MainActivity : ComponentActivity() {
onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") })
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") {
val autoFocus = viewModel.autoFocusSearch
@ -318,7 +347,9 @@ class MainActivity : ComponentActivity() {
onNavigateBack = { navController.popBackStack() },
autoFocus = autoFocus)
}
composable("settings") { SettingsView(settingsNav) }
composable("settings") {
SettingsView(settingsNav = settingsNav, appViewModel = appViewModel)
}
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
@ -378,7 +409,6 @@ class MainActivity : ComponentActivity() {
}
}
}
// Login actions are app wide. If we are told about a browse-to-url, we should render it
// over whatever screen we happen to be on.
loginQRCode.collectAsState().value?.let {
@ -401,7 +431,6 @@ class MainActivity : ComponentActivity() {
}
}
}
// Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog.
lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } }
}
@ -422,7 +451,6 @@ class MainActivity : ComponentActivity() {
fun isAnotherVpnActive(context: Context): Boolean {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetwork
if (activeNetwork != null) {
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
@ -433,7 +461,6 @@ class MainActivity : ComponentActivity() {
}
return false
}
// Returns true if we should render a QR code instead of launching a browser
// for login requests
private fun useQRCodeLogin(): Boolean {
@ -449,7 +476,6 @@ class MainActivity : ComponentActivity() {
if (this::navController.isInitialized) {
val previousEntry = navController.previousBackStackEntry
TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry")
if (previousEntry != null) {
navController.popBackStack(route = "main", inclusive = false)
} else {
@ -478,7 +504,6 @@ class MainActivity : ComponentActivity() {
putExtra(START_AT_ROOT, true)
}
startActivity(intent)
// Cancel coroutine once we've logged in
this@launch.cancel()
}
@ -487,7 +512,6 @@ class MainActivity : ComponentActivity() {
TSLog.e(TAG, "Login: failed to start MainActivity: $e")
}
}
val url = urlString.toUri()
try {
val customTabsIntent = CustomTabsIntent.Builder().build()

@ -16,14 +16,6 @@ object TaildropDirectoryStore {
fun saveFileDirectory(directoryUri: Uri) {
val prefs = App.get().getEncryptedPrefs()
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit()
try {
// Must restart Tailscale because a new LocalBackend with the new directory must be created.
App.get().startLibtailscale(directoryUri.toString())
} catch (e: Exception) {
TSLog.d(
"TaildropDirectoryStore",
"saveFileDirectory: Failed to restart Libtailscale with the new directory: $e")
}
}
@Throws(IOException::class, GeneralSecurityException::class)

@ -1,6 +1,5 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import android.os.Build
@ -32,7 +31,6 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@ -87,7 +85,6 @@ import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions
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.disabled
import com.tailscale.ipn.ui.theme.errorButton
@ -109,10 +106,11 @@ import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.itemsWithDividers
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.MainViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.util.FeatureFlags
import kotlinx.coroutines.flow.emptyFlow
// Navigation actions for the MainView
data class MainViewNavigation(
@ -129,6 +127,7 @@ fun MainView(
loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation,
viewModel: MainViewModel,
appViewModel: AppViewModel
) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthIcon by viewModel.healthIcon.collectAsState()
@ -151,12 +150,9 @@ fun MainView(
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
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
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem,
leadingContent = {
@ -212,30 +208,19 @@ fun MainView(
}
}
})
when (state) {
Ipn.State.Running -> {
viewModel.maybeRequestVpnPermission()
LaunchVpnPermissionIfNeeded(viewModel)
PromptForMissingPermissions(viewModel)
if (!viewModel.skipPromptsForAuthKeyLogin()) {
LaunchedEffect(state) {
if (state == Ipn.State.Running && !isAndroidTV()) {
viewModel.checkIfTaildropDirectorySelected()
}
}
}
if (showKeyExpiry) {
ExpiryNotification(netmap = netmap, action = { viewModel.login() })
}
if (showExitNodePicker.value == ShowHide.Show) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList(
viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
@ -259,25 +244,6 @@ fun MainView(
{ 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 { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
@ -291,7 +257,6 @@ fun MainView(
@Composable
fun TaildropDirectoryPickerPrompt() {
val uriHandler = LocalUriHandler.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) {
Text(text = stringResource(id = R.string.taildrop_directory_picker_body))
Text(
@ -306,10 +271,8 @@ fun TaildropDirectoryPickerPrompt() {
fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current
val shouldRequest by viewModel.requestVpnPermission.collectAsState()
LaunchedEffect(shouldRequest) {
if (!shouldRequest) return@LaunchedEffect
// Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.showVPNPermissionLauncherIfUnauthorized()
@ -322,19 +285,14 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val nodeState by viewModel.nodeState.collectAsState()
val maybePrefs by viewModel.prefs.collectAsState()
val netmap by viewModel.netmap.collectAsState()
// There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs ?: return
// The activeExitNode is the source of truth. The selectedExitNode is only relevant if we
// don't have an active node.
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID
val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
val name = exitNodePeer?.exitNodeName
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
Box(
modifier =
Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) {
@ -359,7 +317,6 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
}
}
}
Box(
modifier =
Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
@ -597,7 +554,6 @@ fun PeerList(
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
var isSearchFocussed by remember { mutableStateOf(false) }
@ -606,7 +562,6 @@ fun PeerList(
val localClipboardManager = LocalClipboardManager.current
// Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375)
val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) {
Search(onSearchBarClick)
@ -653,7 +608,6 @@ fun PeerList(
}
}
}
// Peers display
LazyColumn(
modifier =
@ -661,7 +615,6 @@ fun PeerList(
.weight(1f) // LazyColumn gets the remaining vertical space
.onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) {
// Handle case when no results are found
if (showNoResults) {
item {
@ -677,7 +630,6 @@ fun PeerList(
fontWeight = FontWeight.Light)
}
}
// Iterate over peer sets to display them
var first = true
peerList.forEach { peerSet ->
@ -685,13 +637,11 @@ fun PeerList(
item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() }
}
first = false
if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) }
} else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) }
}
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem(
modifier =
@ -758,7 +708,6 @@ fun PeerList(
@Composable
fun NodesSectionHeader(peerSet: PeerSet) {
Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle(
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
bottomPadding = 8.dp,
@ -770,7 +719,6 @@ fun NodesSectionHeader(peerSet: PeerSet) {
@Composable
fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) {
if (netmap == null) return
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box(
modifier =
@ -801,7 +749,6 @@ fun PromptForMissingPermissions(viewModel: MainViewModel) {
if (viewModel.skipPromptsForAuthKeyLogin()) {
return
}
Permissions.prompt.forEach { (permission, state) ->
ErrorDialog(
title = permission.title,
@ -820,7 +767,6 @@ fun Search(
) {
// Prevent multiple taps
var isNavigating by remember { mutableStateOf(false) }
Box(
modifier =
Modifier.fillMaxWidth()
@ -851,7 +797,6 @@ fun Search(
Modifier.padding(start = 0.dp) // Optional start padding for alignment
)
Spacer(modifier = Modifier.width(4.dp))
// Placeholder Text
Text(
text = stringResource(R.string.search_ellipsis),
@ -869,9 +814,9 @@ fun Search(
@Preview
@Composable
fun MainViewPreview() {
val vpnViewModel = VpnViewModel(App.get())
val vm = MainViewModel(vpnViewModel)
val fakePrompt = emptyFlow<Unit>()
val appViewModel = AppViewModel(App.get(), fakePrompt)
val vm = MainViewModel(appViewModel)
MainView(
{},
MainViewNavigation(
@ -880,5 +825,6 @@ fun MainViewPreview() {
onNavigateToExitNodes = {},
onNavigateToHealth = {},
onNavigateToSearch = {}),
vm)
vm,
appViewModel)
}

@ -40,13 +40,13 @@ import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.viewModel.SettingsNav
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.AppViewModel
@Composable
fun SettingsView(
settingsNav: SettingsNav,
viewModel: SettingsViewModel = viewModel(),
vpnViewModel: VpnViewModel = viewModel()
appViewModel: AppViewModel = viewModel()
) {
val handler = LocalUriHandler.current
@ -55,7 +55,7 @@ fun SettingsView(
val managedByOrganization by viewModel.managedByOrganization.collectAsState()
val tailnetLockEnabled by viewModel.tailNetLockEnabled.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 useTailscaleSubnets by MDMSettings.useTailscaleSubnets.flow.collectAsState()

@ -0,0 +1,119 @@
// 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 _triggerDirectoryPicker = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val triggerDirectoryPicker: SharedFlow<Unit> = _triggerDirectoryPicker
val TAG = "AppViewModel"
init {
observeIncomingTaildrop()
prepareVpn()
}
private fun observeIncomingTaildrop() {
viewModelScope.launch {
taildropPrompt.collect {
TSLog.d(TAG, "Taildrop event received, checking directory")
checkIfTaildropDirectorySelected()
}
}
}
fun requestDirectoryPicker() {
_triggerDirectoryPicker.tryEmit(Unit)
}
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 { requestDirectoryPicker() }
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun setVpnActive(isActive: Boolean) {
_vpnActive.value = isActive
}
fun setVpnPrepared(isPrepared: Boolean) {
_vpnPrepared.value = isPrepared
}
}

@ -1,8 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.Uri
import android.net.VpnService
@ -39,112 +37,89 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import java.time.Duration
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory {
class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T
return MainViewModel(appViewModel) as T
}
throw IllegalArgumentException("Unknown ViewModel 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
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
// The expected state of the VPN toggle
private val _vpnToggleState = MutableStateFlow(false)
val vpnToggleState: StateFlow<Boolean> = _vpnToggleState
// Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be
// invoked until the current operation is complete.
var isToggleInProgress = MutableStateFlow(false)
// Permission to prepare VPN
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission
// Select Taildrop directory
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
private val _showDirectoryPickerInterstitial = MutableStateFlow(false)
val showDirectoryPickerInterstitial: StateFlow<Boolean> = _showDirectoryPickerInterstitial
// The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers
// The list of peers
private val _searchViewPeers = MutableStateFlow<List<PeerSet>>(emptyList())
val searchViewPeers: StateFlow<List<PeerSet>> = _searchViewPeers
// The current state of the IPN for determining view visibility
val ipnState = Notifier.state
// The active search term for filtering peers
private val _searchTerm = MutableStateFlow("")
val searchTerm: StateFlow<String> = _searchTerm
var autoFocusSearch by mutableStateOf(true)
private set
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
// The peer for which the dropdown menu is currently expanded. Null if no menu is expanded
var expandedMenuPeer: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
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
// Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
fun updateSearchTerm(term: String) {
_searchTerm.value = term
}
fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null)
}
fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) {
clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: ""))
}
fun startPing(peer: Tailcfg.Node) {
this.pingViewModel.startPing(peer)
}
fun onPingDismissal() {
this.pingViewModel.handleDismissal()
}
// Returns true if we should skip all of the user-interactive permissions prompts
// (with the exception of the VPN permission prompt)
fun skipPromptsForAuthKeyLogin(): Boolean {
val v = MDMSettings.authKey.flow.value.value
return v != null && v != ""
}
private val peerCategorizer = PeerCategorizer()
init {
viewModelScope.launch {
var previousState: State? = null
combine(Notifier.state, isVpnActive) { state, active -> state to active }
.collect { (currentState, active) ->
// Determine the correct state resource string
stateRes.set(userStringRes(currentState, previousState, active))
// Determine if the VPN toggle should be on
val isOn =
when {
@ -153,15 +128,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
previousState == State.NoState && currentState == State.Starting -> true
else -> false
}
// Update the VPN toggle state
_vpnToggleState.value = isOn
// Update the previous state
previousState = currentState
}
}
viewModelScope.launch {
_searchTerm.debounce(250L).collect { term ->
// run the search as a background task
@ -173,7 +145,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
}
}
viewModelScope.launch {
Notifier.netmap.collect { it ->
it?.let { netmap ->
@ -184,7 +155,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
_peers.value = peerCategorizer.peerSets
_searchViewPeers.value = filteredPeers
}
if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false)
return@let
@ -199,57 +169,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
}
}
viewModelScope.launch {
App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) }
}
}
fun maybeRequestVpnPermission() {
_requestVpnPermission.value = true
}
fun showVPNPermissionLauncherIfUnauthorized() {
val vpnIntent = VpnService.prepare(App.get())
TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent")
if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent)
} else {
vpnViewModel.setVpnPrepared(true)
appViewModel.setVpnPrepared(true)
startVPN()
}
_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) {
if (isToggleInProgress.value) {
// Prevent toggling while a previous toggle is in progress
@ -257,16 +195,13 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
viewModelScope.launch {
checkIfTaildropDirectorySelected()
isToggleInProgress.value = true
try {
val currentState = Notifier.state.value
val isPrepared = vpnViewModel.vpnPrepared.value
if (desiredState) {
// User wants to turn ON the VPN
when {
!isPrepared -> showVPNPermissionLauncherIfUnauthorized()
currentState != Ipn.State.Running -> startVPN()
}
} else {
@ -280,27 +215,19 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
}
}
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
}
fun enableSearchAutoFocus() {
autoFocusSearch = true
}
fun disableSearchAutoFocus() {
autoFocusSearch = false
}
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized
vpnPermissionLauncher = launcher
}
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
directoryPickerLauncher = launcher
}
}
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {
@ -316,4 +243,4 @@ private fun userStringRes(currentState: State?, previousState: State?, vpnActive
currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder
else -> R.string.placeholder
}
}
}

@ -1,15 +1,20 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.util
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import com.tailscale.ipn.TaildropDirectoryStore
import com.tailscale.ipn.ui.util.InputStreamAdapter
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 org.json.JSONObject
import java.io.FileOutputStream
@ -17,38 +22,80 @@ import java.io.IOException
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
data class SafFile(val fd: Int, val uri: String)
object ShareFileHelper : libtailscale.ShareFileHelper {
private var appContext: Context? = null
private var app: libtailscale.Application? = null
private var savedUri: String? = null
private var scope: CoroutineScope? = null
@JvmStatic
fun init(context: Context, uri: String) {
fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) {
appContext = context.applicationContext
this.app = app
savedUri = uri
scope = appScope
Libtailscale.setShareFileHelper(this)
TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri")
}
// A simple data class that holds a SAF OutputStream along with its URI.
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.
private fun openSafFileOutputStream(fileName: String): Pair<String, OutputStream?> {
val context = appContext ?: return "" to null
val dirUri = savedUri ?: return "" to null
val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null
val file =
dir.findFile(fileName)
?: dir.createFile("application/octet-stream", fileName)
?: return "" to null
val os = context.contentResolver.openOutputStream(file.uri, "rw")
return file.uri.toString() to os
}
@Throws(IOException::class)
private fun openWriterFD(fileName: String, offset: Long): Pair<String, SeekableOutputStream> {
val ctx = appContext ?: throw IOException("App context not initialized")
@ -60,20 +107,18 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
dir.findFile(fileName)
?: dir.createFile("application/octet-stream", fileName)
?: throw IOException("Failed to create file: $fileName")
val pfd =
ctx.contentResolver.openFileDescriptor(file.uri, "rw")
?: throw IOException("Failed to open file descriptor for ${file.uri}")
val fos = FileOutputStream(pfd.fileDescriptor)
if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0)
return file.uri.toString() to SeekableOutputStream(fos, pfd)
}
private val currentUri = ConcurrentHashMap<String, String>()
@Throws(IOException::class)
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")
@ -84,22 +129,20 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
@Throws(IOException::class)
override fun getFileURI(fileName: String): String {
runBlocking { waitUntilTaildropDirReady() }
currentUri[fileName]?.let {
return it
}
val ctx = appContext ?: throw IOException("App context not initialized")
val dirStr = savedUri ?: throw IOException("No saved directory URI")
val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr))
?: throw IOException("Invalid tree URI: $dirStr")
val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName")
val uri = file.uri.toString()
currentUri[fileName] = uri
return uri
}
@Throws(IOException::class)
override fun renameFile(oldPath: String, targetName: String): String {
val ctx = appContext ?: throw IOException("not initialized")
@ -108,7 +151,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val dir =
DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri))
?: throw IOException("cannot open dir $dirUri")
var finalName = targetName
dir.findFile(finalName)?.let { existing ->
if (lengthOfUri(ctx, existing.uri) == 0L) {
@ -117,7 +160,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
finalName = generateNewFilename(finalName)
}
}
try {
DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri ->
runCatching { ctx.contentResolver.delete(srcUri, null, null) }
@ -125,14 +168,14 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
return newUri.toString()
}
} 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 =
dir.createFile("application/octet-stream", finalName)
?: throw IOException("createFile failed for $finalName")
ctx.contentResolver.openInputStream(srcUri).use { inp ->
ctx.contentResolver.openOutputStream(dest.uri, "w").use { out ->
if (inp == null || out == null) {
@ -142,15 +185,13 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
inp.copyTo(out)
}
}
ctx.contentResolver.delete(srcUri, null, null)
cleanupPartials(dir, targetName)
return dest.uri.toString()
}
private fun lengthOfUri(ctx: Context, uri: Uri): Long =
ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 }
// delete any stray “.partial” files for this base name
private fun cleanupPartials(dir: DocumentFile, base: String) {
for (child in dir.listFiles()) {
@ -160,21 +201,18 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
}
}
}
@Throws(IOException::class)
override fun deleteFile(uri: String) {
runBlocking { waitUntilTaildropDirReady() }
val ctx = appContext ?: throw IOException("DeleteFile: not initialized")
val uri = Uri.parse(uri)
val doc =
DocumentFile.fromSingleUri(ctx, uri)
?: throw IOException("DeleteFile: cannot resolve URI $uri")
if (!doc.delete()) {
throw IOException("DeleteFile: delete() returned false for $uri")
}
}
@Throws(IOException::class)
override fun getFileInfo(fileName: String): String {
val context = appContext ?: throw IOException("app context not initialized")
@ -182,41 +220,32 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val dir =
DocumentFile.fromTreeUri(context, Uri.parse(dirUri))
?: throw IOException("could not resolve SAF root")
val file =
dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory")
val name = file.name ?: throw IOException("file name missing for $fileName")
val size = file.length()
val modTime = file.lastModified()
return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}"""
}
private fun jsonEscape(s: String): String {
return JSONObject.quote(s)
}
fun generateNewFilename(filename: String): String {
val dotIndex = filename.lastIndexOf('.')
val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename
val extension = if (dotIndex != -1) filename.substring(dotIndex) else ""
val uuid = UUID.randomUUID()
return "$baseName-$uuid$extension"
}
fun listPartialFiles(suffix: String): Array<String> {
val context = appContext ?: return emptyArray()
val rootUri = savedUri ?: return emptyArray()
val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray()
return dir.listFiles()
.filter { it.name?.endsWith(suffix) == true }
.mapNotNull { it.name }
.toTypedArray()
}
@Throws(IOException::class)
override fun listFilesJSON(suffix: String): String {
val list = listPartialFiles(suffix)
@ -225,7 +254,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
}
return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]")
}
@Throws(IOException::class)
override fun openFileReader(name: String): libtailscale.InputStream {
val context = appContext ?: throw IOException("app context not initialized")
@ -233,37 +261,32 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val dir =
DocumentFile.fromTreeUri(context, Uri.parse(rootUri))
?: throw IOException("could not open SAF root")
val suffix = name.substringAfterLast('.', ".$name")
val file =
dir.listFiles().firstOrNull {
val fname = it.name ?: return@firstOrNull false
fname.endsWith(suffix, ignoreCase = false)
} ?: throw IOException("no file ending with \"$suffix\" in SAF directory")
val inStream =
context.contentResolver.openInputStream(file.uri)
?: throw IOException("openInputStream returned null for ${file.uri}")
return InputStreamAdapter(inStream)
}
fun setUri(uri: String) {
savedUri = uri
}
private class SeekableOutputStream(
private val fos: FileOutputStream,
private val pfd: ParcelFileDescriptor
) : OutputStream() {
private var closed = false
override fun write(b: Int) = fos.write(b)
override fun write(b: ByteArray) = fos.write(b)
override fun write(b: ByteArray, off: Int, len: Int) {
fos.write(b, off, len)
}
override fun close() {
if (!closed) {
closed = true
@ -276,7 +299,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
}
}
}
override fun flush() = fos.flush()
}
}
}

@ -58,8 +58,6 @@ type App struct {
backend *ipnlocal.LocalBackend
ready sync.WaitGroup
backendMu sync.Mutex
backendRestartCh chan struct{}
}
func start(dataDir, directFileRoot string, appCtx AppContext) Application {
@ -114,23 +112,6 @@ type backend struct {
type settingsFunc func(*router.Config, *dns.OSConfig) 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)
hostinfo.SetOSVersion(a.osVersion())
hostinfo.SetPackage(a.appCtx.GetInstallSource())
@ -338,7 +319,6 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
ext.SetFileOps(newAndroidFileOps(a.shareFileHelper))
ext.SetDirectFileRoot(a.directFileRoot)
}
if err != nil {
@ -368,14 +348,9 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
func (a *App) watchFileOpsChanges() {
for {
select {
case newPath := <-onFilePath:
log.Printf("Got new directFileRoot")
a.directFileRoot = newPath
a.backendRestartCh <- struct{}{}
case helper := <-onShareFileHelper:
log.Printf("Got shareFIleHelper")
log.Printf("Got ShareFileHelper")
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 = 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.

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

@ -32,10 +32,9 @@ const (
func newApp(dataDir, directFileRoot string, appCtx AppContext) Application {
a := &App{
directFileRoot: directFileRoot,
dataDir: dataDir,
appCtx: appCtx,
backendRestartCh: make(chan struct{}, 1),
directFileRoot: directFileRoot,
dataDir: dataDir,
appCtx: appCtx,
}
a.ready.Add(2)

Loading…
Cancel
Save