From b3c6414ad86ee68ed1f51220910ae25642ae60c8 Mon Sep 17 00:00:00 2001 From: kari-ts Date: Fri, 13 Jun 2025 16:55:51 -0700 Subject: [PATCH] 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 --- .../src/main/java/com/tailscale/ipn/App.kt | 18 +-- .../java/com/tailscale/ipn/MainActivity.kt | 81 +++++++++--- .../tailscale/ipn/TaildropDirectoryStore.kt | 8 -- .../com/tailscale/ipn/ui/view/MainView.kt | 2 - .../com/tailscale/ipn/ui/view/SettingsView.kt | 6 +- .../ipn/ui/viewModel/AppViewModel.kt | 115 ++++++++++++++++++ .../ipn/ui/viewModel/MainViewModel.kt | 55 ++------- .../ipn/ui/viewModel/VpnViewModel.kt | 65 ---------- .../com/tailscale/ipn/util/ShareFileHelper.kt | 86 +++++++++++-- go.toolchain.rev | 2 +- libtailscale/backend.go | 36 ++---- libtailscale/callbacks.go | 3 - libtailscale/fileops.go | 4 +- libtailscale/interfaces.go | 8 +- libtailscale/tailscale.go | 8 +- 15 files changed, 299 insertions(+), 198 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 67a0a62f3a..ceffa09f03 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -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 = diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index dbcbc3fb81..35230b8667 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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 - 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) } diff --git a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt index be02c95802..c168d7ddde 100644 --- a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt +++ b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt @@ -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) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 0a848a45f3..6b3fef3801 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index e29e9882af..c98f18c1f8 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -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() diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt new file mode 100644 index 0000000000..6e69667a8f --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt @@ -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) : + ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): 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) : + 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 = _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 = _vpnActive + // Select Taildrop directory + var directoryPickerLauncher: ActivityResultLauncher? = null + private val _showDirectoryPickerInterstitial = MutableSharedFlow(extraBufferCapacity = 1) + val showDirectoryPickerInterstitial: SharedFlow = _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 + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 9634a869c1..8751bb5d31 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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 create(modelClass: Class): 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 = 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 = _requestVpnPermission - // Select Taildrop directory - private var directoryPickerLauncher: ActivityResultLauncher? = null - private val _showDirectoryPickerInterstitial = MutableStateFlow(false) - val showDirectoryPickerInterstitial: StateFlow = _showDirectoryPickerInterstitial - // The list of peers private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers @@ -97,9 +90,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { var pingViewModel: PingViewModel = PingViewModel() - val isVpnPrepared: StateFlow = vpnViewModel.vpnPrepared + val isVpnPrepared: StateFlow = appViewModel.vpnPrepared - val isVpnActive: StateFlow = vpnViewModel.vpnActive + val isVpnActive: StateFlow = 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) { - directoryPickerLauncher = launcher - } } private fun userStringRes(currentState: State?, previousState: State?, vpnActive: Boolean): Int { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt deleted file mode 100644 index a6ee734284..0000000000 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/VpnViewModel.kt +++ /dev/null @@ -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 create(modelClass: Class): 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 = _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 = _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 - } -} diff --git a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt index fed568d095..388428de7e 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -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() + val taildropPrompt = MutableSharedFlow(replay = 0) + + fun observeTaildropPrompt(): Flow = taildropPrompt + + @Volatile private var directoryReady: CompletableDeferred? = 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 + } } diff --git a/go.toolchain.rev b/go.toolchain.rev index 33aa564236..12fe8b8386 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -1cd3bf1a6eaf559aa8c00e749289559c884cef09 +98e8c99c256a5aeaa13725d2e43fdd7f465ba200 \ No newline at end of file diff --git a/libtailscale/backend.go b/libtailscale/backend.go index c8d5f4ad79..8c52fd59c5 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -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") diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go index 3e1a88fcc1..9daec5c6b5 100644 --- a/libtailscale/callbacks.go +++ b/libtailscale/callbacks.go @@ -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. diff --git a/libtailscale/fileops.go b/libtailscale/fileops.go index 241097c6b4..fb0acde5e1 100644 --- a/libtailscale/fileops.go +++ b/libtailscale/fileops.go @@ -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") } diff --git a/libtailscale/interfaces.go b/libtailscale/interfaces.go index 44b9616d88..a13c51c05f 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -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 -} diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 3a785fabb9..b04ca52d8d 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -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)