android: move taildrop directory selector out of onboarding

-ShareFileHelper manages directory readiness; when a file is being shared to the device, it emits a signal to prompt the user to pick a directory
-Remove MDM auth key check; there is no longer any need to make assumptions about Taildrop usage, and we only show the directory selector when they are receiving a Taildropped file
-Listen for Taildrop receipt in application view model (formerly VpnViewModel, now renamed due to its expanded scope), since Taildrop can occur even without MainActivity, and move dir picker out of MainView
-Switch from StateFlow to SharedFlow since this is an event that only needs to be handled once rather than a persistent UI state.
-ShareFileHelper keeps track of Taildrop dir rather than the Taildrop extension managerOptions; this allows the correct directory to be used without having to send a new request or restart LocalBackend
-Don't restart LocalBackend on Taildrop dir selection because this is no longer necessary
Follow-up: implement resume Taildrop in SAF

Updates tailscale/corp#29211

Signed-off-by: kari-ts <kari@tailscale.com>
kari/movedirsel
kari-ts 6 months ago
parent 460736a151
commit b3c6414ad8

@ -33,8 +33,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
@ -211,15 +211,17 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
* 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) {
@ -227,7 +229,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
result.fold(
onSuccess = { onSuccess?.invoke() },
onFailure = { error ->
TSLog.d("TAG", "Set want running: failed to update preferences: ${error.message}")
TSLog.d(TAG, "Set want running: failed to update preferences: ${error.message}")
})
}
Client(applicationScope)
@ -400,7 +402,7 @@ open class UninitializedApp : Application() {
private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel
lateinit var appViewModel: AppViewModel
@JvmStatic
fun get(): UninitializedApp {
@ -587,8 +589,8 @@ open class UninitializedApp : Application() {
return builtInDisallowedPackageNames + userDisallowed
}
fun getAppScopedViewModel(): VpnViewModel {
return vpnViewModel
fun getAppScopedViewModel(): AppViewModel {
return appViewModel
}
val builtInDisallowedPackageNames: List<String> =

@ -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,47 @@ 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.AppViewModelFactory
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 val appViewModel: AppViewModel by viewModels {
AppViewModelFactory(
application = this.application,
taildropPrompt = ShareFileHelper.taildropPrompt
)
}
private lateinit var vpnViewModel: VpnViewModel
private val viewModel: MainViewModel by viewModels {
MainViewModelFactory(appViewModel)
}
val permissionsViewModel: PermissionsViewModel by viewModels()
companion object {
@ -132,7 +148,6 @@ class MainActivity : ComponentActivity() {
// grab app to make sure it initializes
App.get()
vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java)
val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
MDMSettings.update(App.get(), rm)
@ -154,7 +169,7 @@ class MainActivity : ComponentActivity() {
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 +177,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)
@ -198,9 +213,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")
}
@ -219,9 +235,38 @@ class MainActivity : ComponentActivity() {
}
}
viewModel.setDirectoryPickerLauncher(directoryPickerLauncher)
appViewModel.directoryPickerLauncher = directoryPickerLauncher
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()
AppTheme {
@ -308,7 +353,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 +367,11 @@ 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) }

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

@ -151,8 +151,6 @@ 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)

@ -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,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
}
}

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.getValue
@ -12,7 +11,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@ -38,18 +36,18 @@ 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))
@ -66,11 +64,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
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
@ -97,9 +90,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
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
@ -214,41 +207,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
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()) {
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
@ -256,11 +220,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
}
viewModelScope.launch {
checkIfTaildropDirectorySelected()
isToggleInProgress.value = true
try {
val currentState = Notifier.state.value
val isPrepared = vpnViewModel.vpnPrepared.value
val isPrepared = appViewModel.vpnPrepared.value
if (desiredState) {
// User wants to turn ON the VPN
@ -296,10 +259,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// 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 {

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

@ -6,7 +6,14 @@ package com.tailscale.ipn.util
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import com.tailscale.ipn.TaildropDirectoryStore
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 java.io.IOException
import java.io.OutputStream
@ -17,21 +24,31 @@ 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)
// Cache for streams; keyed by file name and savedUri.
private val streamCache = ConcurrentHashMap<String, SafStream>()
val taildropPrompt = MutableSharedFlow<Unit>(replay = 0)
fun observeTaildropPrompt(): Flow<Unit> = taildropPrompt
@Volatile private var directoryReady: CompletableDeferred<Unit>? = null
// A helper function that creates (or reuses) a SafStream for a given file.
private fun createStreamCached(fileName: String): SafStream {
val key = "$fileName|$savedUri"
@ -73,30 +90,74 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
}
}
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)
}
// This method returns a SafStream containing the SAF URI and its corresponding OutputStream.
override fun openFileWriter(fileName: String): libtailscale.OutputStream {
runBlocking { waitUntilTaildropDirReady() }
val stream = createStreamCached(fileName)
return OutputStreamAdapter(stream.stream)
}
override fun openFileURI(fileName: String): String {
runBlocking { waitUntilTaildropDirReady() }
val safFile = createStreamCached(fileName)
return safFile.uri
}
override fun renamePartialFile(
partialUri: String,
targetDirUri: String,
targetName: String
): String {
try {
val context = appContext ?: throw IllegalStateException("appContext is null")
val partialUriObj = Uri.parse(partialUri)
val targetDirUriObj = Uri.parse(targetDirUri)
TSLog.d("ShareFileHelper", "renamePartialFile with uri: $partialUri and dir: $savedUri")
if (partialUriObj.scheme != "content") {
throw IllegalArgumentException("Expected SAF URI for partial file, got: $partialUri")
}
val targetDir =
DocumentFile.fromTreeUri(context, targetDirUriObj)
?: throw IllegalStateException(
"Unable to get target directory from URI: $targetDirUri")
DocumentFile.fromTreeUri(context, Uri.parse(savedUri))
?: throw IllegalStateException("Invalid target directory URI: $savedUri")
var finalTargetName = targetName
var destFile = targetDir.findFile(finalTargetName)
@ -111,14 +172,15 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
context.contentResolver.openInputStream(partialUriObj)?.use { input ->
context.contentResolver.openOutputStream(destFile.uri)?.use { output ->
input.copyTo(output)
} ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}")
} ?: throw IOException("Unable to open input stream for URI: $partialUri")
} ?: throw IOException("Unable to open output stream for URI: $finalTargetName")
} ?: throw IOException("Unable to open input stream for URI $partialUri")
DocumentFile.fromSingleUri(context, partialUriObj)?.delete()
return destFile.uri.toString()
} catch (e: Exception) {
throw IOException(
"Failed to rename partial file from URI $partialUri to final file in $targetDirUri with name $targetName: ${e.message}",
"Failed to rename partial file from URI $partialUri to final file in $savedUri with name $targetName: ${e.message}",
e)
}
}
@ -131,4 +193,8 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val uuid = UUID.randomUUID()
return "$baseName-$uuid$extension"
}
fun setUri(uri: String) {
savedUri = uri
}
}

@ -1 +1 @@
1cd3bf1a6eaf559aa8c00e749289559c884cef09
98e8c99c256a5aeaa13725d2e43fdd7f465ba200

@ -59,7 +59,7 @@ type App struct {
ready sync.WaitGroup
backendMu sync.Mutex
backendRestartCh chan struct{}
taildropReady chan struct{}
}
func start(dataDir, directFileRoot string, appCtx AppContext) Application {
@ -114,23 +114,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())
@ -337,8 +320,12 @@ 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 {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in taildrop extension init: %v", r)
}
}()
ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper))
ext.SetDirectFileRoot(a.directFileRoot)
}
if err != nil {
@ -368,18 +355,17 @@ 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{}{}
}
}
}
func (a *App) WaitForTaildropReady() {
<-a.taildropReady
}
func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore")

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

@ -29,8 +29,8 @@ func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, stri
return outputStream, uri, nil
}
func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) {
newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName)
func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetName string) (string, error) {
newURI := ops.helper.RenamePartialFile(partialUri, targetName)
if newURI == "" {
return "", fmt.Errorf("failed to rename partial file via SAF")
}

@ -125,6 +125,8 @@ type Application interface {
// on every new ipn.Notify message. The returned NotificationManager
// allows the watcher to stop watching notifications.
WatchNotifications(mask int, cb NotificationCallback) NotificationManager
WaitForTaildropReady()
}
// FileParts is an array of multiple FileParts.
@ -182,7 +184,7 @@ type ShareFileHelper interface {
// RenamePartialFile takes SAF URIs and a target file name,
// and returns the new SAF URI and an error.
RenamePartialFile(partialUri string, targetDirUri string, targetName string) string
RenamePartialFile(partialUri string, targetName string) string
}
// The below are global callbacks that allow the Java application to notify Go
@ -221,7 +223,3 @@ func SetShareFileHelper(fileHelper ShareFileHelper) {
onShareFileHelper <- fileHelper
}
}
func SetDirectFileRoot(filePath string) {
onFilePath <- filePath
}

@ -32,10 +32,10 @@ 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,
taildropReady: make(chan struct{}, 1),
}
a.ready.Add(2)

Loading…
Cancel
Save