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.model.Netmap
import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.AppViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.ui.viewModel.AppViewModelFactory
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.ShareFileHelper
import com.tailscale.ipn.util.TSLog import com.tailscale.ipn.util.TSLog
@ -211,15 +211,17 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
* Tailscale because directFileRoot must be set before LocalBackend starts being used. * Tailscale because directFileRoot must be set before LocalBackend starts being used.
*/ */
fun startLibtailscale(directFileRoot: String) { fun startLibtailscale(directFileRoot: String) {
ShareFileHelper.init(this, directFileRoot)
app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this) app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this)
ShareFileHelper.init(this, app, directFileRoot, applicationScope)
Request.setApp(app) Request.setApp(app)
Notifier.setApp(app) Notifier.setApp(app)
Notifier.start(applicationScope) Notifier.start(applicationScope)
} }
private fun initViewModels() { private fun initViewModels() {
vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) appViewModel =
ViewModelProvider(this, AppViewModelFactory(this, ShareFileHelper.observeTaildropPrompt()))
.get(AppViewModel::class.java)
} }
fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) { fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) {
@ -227,7 +229,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
result.fold( result.fold(
onSuccess = { onSuccess?.invoke() }, onSuccess = { onSuccess?.invoke() },
onFailure = { error -> 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) Client(applicationScope)
@ -400,7 +402,7 @@ open class UninitializedApp : Application() {
private lateinit var appInstance: UninitializedApp private lateinit var appInstance: UninitializedApp
lateinit var notificationManager: NotificationManagerCompat lateinit var notificationManager: NotificationManagerCompat
lateinit var vpnViewModel: VpnViewModel lateinit var appViewModel: AppViewModel
@JvmStatic @JvmStatic
fun get(): UninitializedApp { fun get(): UninitializedApp {
@ -587,8 +589,8 @@ open class UninitializedApp : Application() {
return builtInDisallowedPackageNames + userDisallowed return builtInDisallowedPackageNames + userDisallowed
} }
fun getAppScopedViewModel(): VpnViewModel { fun getAppScopedViewModel(): AppViewModel {
return vpnViewModel return appViewModel
} }
val builtInDisallowedPackageNames: List<String> = val builtInDisallowedPackageNames: List<String> =

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

@ -16,14 +16,6 @@ object TaildropDirectoryStore {
fun saveFileDirectory(directoryUri: Uri) { fun saveFileDirectory(directoryUri: Uri) {
val prefs = App.get().getEncryptedPrefs() val prefs = App.get().getEncryptedPrefs()
prefs.edit().putString(PREF_KEY_SAF_URI, directoryUri.toString()).commit() 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) @Throws(IOException::class, GeneralSecurityException::class)

@ -151,8 +151,6 @@ fun MainView(
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
val showDirectoryPickerInterstitial by
viewModel.showDirectoryPickerInterstitial.collectAsState()
// Hide the header only on Android TV when the user needs to login // Hide the header only on Android TV when the user needs to login
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)

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

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

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.net.VpnService import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -12,7 +11,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -38,18 +36,18 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Duration import java.time.Duration
class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) { if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(vpnViewModel) as T return MainViewModel(appViewModel) as T
} }
throw IllegalArgumentException("Unknown ViewModel class") throw IllegalArgumentException("Unknown ViewModel class")
} }
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
@ -66,11 +64,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private val _requestVpnPermission = MutableStateFlow(false) private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission: StateFlow<Boolean> = _requestVpnPermission 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 // The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList()) private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers val peers: StateFlow<List<PeerSet>> = _peers
@ -97,9 +90,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
var pingViewModel: PingViewModel = PingViewModel() var pingViewModel: PingViewModel = PingViewModel()
val isVpnPrepared: StateFlow<Boolean> = vpnViewModel.vpnPrepared val isVpnPrepared: StateFlow<Boolean> = appViewModel.vpnPrepared
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive val isVpnActive: StateFlow<Boolean> = appViewModel.vpnActive
var searchJob: Job? = null var searchJob: Job? = null
@ -214,41 +207,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
if (vpnIntent != null) { if (vpnIntent != null) {
vpnPermissionLauncher?.launch(vpnIntent) vpnPermissionLauncher?.launch(vpnIntent)
} else { } else {
vpnViewModel.setVpnPrepared(true) appViewModel.setVpnPrepared(true)
startVPN() startVPN()
} }
_requestVpnPermission.value = false // reset _requestVpnPermission.value = false // reset
} }
fun showDirectoryPickerLauncher() {
_showDirectoryPickerInterstitial.set(false)
directoryPickerLauncher?.launch(null)
}
fun checkIfTaildropDirectorySelected() {
if (skipPromptsForAuthKeyLogin()) {
return
}
val app = App.get()
val storedUri = app.getStoredDirectoryUri()
if (storedUri == null) {
// No stored URI, so launch the directory picker.
_showDirectoryPickerInterstitial.set(true)
return
}
val documentFile = DocumentFile.fromTreeUri(app, storedUri)
if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) {
TSLog.d(
"MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.")
_showDirectoryPickerInterstitial.set(true)
} else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
}
}
fun toggleVpn(desiredState: Boolean) { fun toggleVpn(desiredState: Boolean) {
if (isToggleInProgress.value) { if (isToggleInProgress.value) {
// Prevent toggling while a previous toggle is in progress // Prevent toggling while a previous toggle is in progress
@ -256,11 +220,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
checkIfTaildropDirectorySelected()
isToggleInProgress.value = true isToggleInProgress.value = true
try { try {
val currentState = Notifier.state.value val currentState = Notifier.state.value
val isPrepared = vpnViewModel.vpnPrepared.value val isPrepared = appViewModel.vpnPrepared.value
if (desiredState) { if (desiredState) {
// User wants to turn ON the VPN // 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 // No intent means we're already authorized
vpnPermissionLauncher = launcher vpnPermissionLauncher = launcher
} }
fun setDirectoryPickerLauncher(launcher: ActivityResultLauncher<Uri?>) {
directoryPickerLauncher = launcher
}
} }
private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int {

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

@ -6,7 +6,14 @@ package com.tailscale.ipn.util
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.tailscale.ipn.TaildropDirectoryStore
import com.tailscale.ipn.ui.util.OutputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import libtailscale.Libtailscale import libtailscale.Libtailscale
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
@ -17,21 +24,31 @@ data class SafFile(val fd: Int, val uri: String)
object ShareFileHelper : libtailscale.ShareFileHelper { object ShareFileHelper : libtailscale.ShareFileHelper {
private var appContext: Context? = null private var appContext: Context? = null
private var app: libtailscale.Application? = null
private var savedUri: String? = null private var savedUri: String? = null
private var scope: CoroutineScope? = null
@JvmStatic @JvmStatic
fun init(context: Context, uri: String) { fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) {
appContext = context.applicationContext appContext = context.applicationContext
this.app = app
savedUri = uri savedUri = uri
scope = appScope
Libtailscale.setShareFileHelper(this) Libtailscale.setShareFileHelper(this)
TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri")
} }
// A simple data class that holds a SAF OutputStream along with its URI. // A simple data class that holds a SAF OutputStream along with its URI.
data class SafStream(val uri: String, val stream: OutputStream) data class SafStream(val uri: String, val stream: OutputStream)
// Cache for streams; keyed by file name and savedUri. // Cache for streams; keyed by file name and savedUri.
private val streamCache = ConcurrentHashMap<String, SafStream>() 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. // A helper function that creates (or reuses) a SafStream for a given file.
private fun createStreamCached(fileName: String): SafStream { private fun createStreamCached(fileName: String): SafStream {
val key = "$fileName|$savedUri" 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. // This method returns a SafStream containing the SAF URI and its corresponding OutputStream.
override fun openFileWriter(fileName: String): libtailscale.OutputStream { override fun openFileWriter(fileName: String): libtailscale.OutputStream {
runBlocking { waitUntilTaildropDirReady() }
val stream = createStreamCached(fileName) val stream = createStreamCached(fileName)
return OutputStreamAdapter(stream.stream) return OutputStreamAdapter(stream.stream)
} }
override fun openFileURI(fileName: String): String { override fun openFileURI(fileName: String): String {
runBlocking { waitUntilTaildropDirReady() }
val safFile = createStreamCached(fileName) val safFile = createStreamCached(fileName)
return safFile.uri return safFile.uri
} }
override fun renamePartialFile( override fun renamePartialFile(
partialUri: String, partialUri: String,
targetDirUri: String,
targetName: String targetName: String
): String { ): String {
try { try {
val context = appContext ?: throw IllegalStateException("appContext is null") val context = appContext ?: throw IllegalStateException("appContext is null")
val partialUriObj = Uri.parse(partialUri) 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 = val targetDir =
DocumentFile.fromTreeUri(context, targetDirUriObj) DocumentFile.fromTreeUri(context, Uri.parse(savedUri))
?: throw IllegalStateException( ?: throw IllegalStateException("Invalid target directory URI: $savedUri")
"Unable to get target directory from URI: $targetDirUri")
var finalTargetName = targetName var finalTargetName = targetName
var destFile = targetDir.findFile(finalTargetName) var destFile = targetDir.findFile(finalTargetName)
@ -111,14 +172,15 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
context.contentResolver.openInputStream(partialUriObj)?.use { input -> context.contentResolver.openInputStream(partialUriObj)?.use { input ->
context.contentResolver.openOutputStream(destFile.uri)?.use { output -> context.contentResolver.openOutputStream(destFile.uri)?.use { output ->
input.copyTo(output) input.copyTo(output)
} ?: throw IOException("Unable to open output stream for URI: ${destFile.uri}") } ?: throw IOException("Unable to open output stream for URI: $finalTargetName")
} ?: throw IOException("Unable to open input stream for URI: $partialUri") } ?: throw IOException("Unable to open input stream for URI $partialUri")
DocumentFile.fromSingleUri(context, partialUriObj)?.delete() DocumentFile.fromSingleUri(context, partialUriObj)?.delete()
return destFile.uri.toString() return destFile.uri.toString()
} catch (e: Exception) { } catch (e: Exception) {
throw IOException( 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) e)
} }
} }
@ -131,4 +193,8 @@ object ShareFileHelper : libtailscale.ShareFileHelper {
val uuid = UUID.randomUUID() val uuid = UUID.randomUUID()
return "$baseName-$uuid$extension" return "$baseName-$uuid$extension"
} }
fun setUri(uri: String) {
savedUri = uri
}
} }

@ -1 +1 @@
1cd3bf1a6eaf559aa8c00e749289559c884cef09 98e8c99c256a5aeaa13725d2e43fdd7f465ba200

@ -59,7 +59,7 @@ type App struct {
ready sync.WaitGroup ready sync.WaitGroup
backendMu sync.Mutex backendMu sync.Mutex
backendRestartCh chan struct{} taildropReady chan struct{}
} }
func start(dataDir, directFileRoot string, appCtx AppContext) Application { func start(dataDir, directFileRoot string, appCtx AppContext) Application {
@ -114,23 +114,6 @@ type backend struct {
type settingsFunc func(*router.Config, *dns.OSConfig) error type settingsFunc func(*router.Config, *dns.OSConfig) error
func (a *App) runBackend(ctx context.Context) error { func (a *App) runBackend(ctx context.Context) error {
for {
err := a.runBackendOnce(ctx)
if err != nil {
log.Printf("runBackendOnce error: %v", err)
}
// Wait for a restart trigger
<-a.backendRestartCh
}
}
func (a *App) runBackendOnce(ctx context.Context) error {
select {
case <-a.backendRestartCh:
default:
}
paths.AppSharedDir.Store(a.dataDir) paths.AppSharedDir.Store(a.dataDir)
hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetOSVersion(a.osVersion())
hostinfo.SetPackage(a.appCtx.GetInstallSource()) hostinfo.SetPackage(a.appCtx.GetInstallSource())
@ -337,8 +320,12 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
} }
lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0)
if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in taildrop extension init: %v", r)
}
}()
ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper)) ext.SetFileOps(NewAndroidFileOps(a.shareFileHelper))
ext.SetDirectFileRoot(a.directFileRoot)
} }
if err != nil { if err != nil {
@ -368,18 +355,17 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore,
func (a *App) watchFileOpsChanges() { func (a *App) watchFileOpsChanges() {
for { for {
select { select {
case newPath := <-onFilePath:
log.Printf("Got new directFileRoot")
a.directFileRoot = newPath
a.backendRestartCh <- struct{}{}
case helper := <-onShareFileHelper: case helper := <-onShareFileHelper:
log.Printf("Got shareFIleHelper") log.Printf("Got shareFileHelper")
a.shareFileHelper = helper a.shareFileHelper = helper
a.backendRestartCh <- struct{}{}
} }
} }
} }
func (a *App) WaitForTaildropReady() {
<-a.taildropReady
}
func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool { func (b *backend) isConfigNonNilAndDifferent(rcfg *router.Config, dcfg *dns.OSConfig) bool {
if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) { if reflect.DeepEqual(rcfg, b.lastCfg) && reflect.DeepEqual(dcfg, b.lastDNSCfg) {
b.logger.Logf("isConfigNonNilAndDifferent: no change to Routes or DNS, ignore") 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 receives ShareFileHelper references when the app is initialized so that files can be received via Storage Access Framework
onShareFileHelper = make(chan ShareFileHelper, 1) onShareFileHelper = make(chan ShareFileHelper, 1)
// onFilePath receives the SAF path used for Taildrop
onFilePath = make(chan string)
) )
// ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available. // ifname is the interface name retrieved from LinkProperties on network change. An empty string is used if there is no network available.

@ -29,8 +29,8 @@ func (ops *AndroidFileOps) OpenFileWriter(filename string) (io.WriteCloser, stri
return outputStream, uri, nil return outputStream, uri, nil
} }
func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetDirUri, targetName string) (string, error) { func (ops *AndroidFileOps) RenamePartialFile(partialUri, targetName string) (string, error) {
newURI := ops.helper.RenamePartialFile(partialUri, targetDirUri, targetName) newURI := ops.helper.RenamePartialFile(partialUri, targetName)
if newURI == "" { if newURI == "" {
return "", fmt.Errorf("failed to rename partial file via SAF") 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 // on every new ipn.Notify message. The returned NotificationManager
// allows the watcher to stop watching notifications. // allows the watcher to stop watching notifications.
WatchNotifications(mask int, cb NotificationCallback) NotificationManager WatchNotifications(mask int, cb NotificationCallback) NotificationManager
WaitForTaildropReady()
} }
// FileParts is an array of multiple FileParts. // FileParts is an array of multiple FileParts.
@ -182,7 +184,7 @@ type ShareFileHelper interface {
// RenamePartialFile takes SAF URIs and a target file name, // RenamePartialFile takes SAF URIs and a target file name,
// and returns the new SAF URI and an error. // 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 // The below are global callbacks that allow the Java application to notify Go
@ -221,7 +223,3 @@ func SetShareFileHelper(fileHelper ShareFileHelper) {
onShareFileHelper <- fileHelper onShareFileHelper <- fileHelper
} }
} }
func SetDirectFileRoot(filePath string) {
onFilePath <- filePath
}

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

Loading…
Cancel
Save