diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index fdbd295b58..ef89a23c35 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) @@ -390,7 +392,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 { @@ -577,8 +579,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..87d128f277 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,41 @@ import com.tailscale.ipn.ui.view.MullvadInfoView import com.tailscale.ipn.ui.view.NotificationsView import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView +import com.tailscale.ipn.ui.view.PrimaryActionButton import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SubnetRoutingView import com.tailscale.ipn.ui.view.TaildropDirView +import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModelFactory import com.tailscale.ipn.ui.viewModel.PermissionsViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav -import com.tailscale.ipn.ui.viewModel.VpnViewModel +import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import libtailscale.Libtailscale class MainActivity : ComponentActivity() { private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher private val viewModel: MainViewModel by lazy { val app = App.get() - vpnViewModel = app.getAppScopedViewModel() - ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java) + appViewModel = app.getAppScopedViewModel() + ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java) } - private lateinit var vpnViewModel: VpnViewModel + private lateinit var appViewModel: AppViewModel val permissionsViewModel: PermissionsViewModel by viewModels() companion object { @@ -132,7 +142,7 @@ class MainActivity : ComponentActivity() { // grab app to make sure it initializes App.get() - vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java) + appViewModel = ViewModelProvider(App.get()).get(AppViewModel::class.java) val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager MDMSettings.update(App.get(), rm) @@ -154,7 +164,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 +172,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 +208,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 +230,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 +348,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 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..95dc2a4edb 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 @@ -109,10 +109,11 @@ import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState import com.tailscale.ipn.ui.viewModel.MainViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.util.FeatureFlags +import com.tailscale.ipn.util.ShareFileHelper // Navigation actions for the MainView data class MainViewNavigation( @@ -129,6 +130,7 @@ fun MainView( loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewModel: MainViewModel, + appViewModel: AppViewModel ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState() @@ -152,7 +154,7 @@ fun MainView( val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) val showDirectoryPickerInterstitial by - viewModel.showDirectoryPickerInterstitial.collectAsState() + appViewModel.showDirectoryPickerInterstitial.collectAsState() // Hide the header only on Android TV when the user needs to login val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) @@ -219,14 +221,6 @@ fun MainView( LaunchVpnPermissionIfNeeded(viewModel) PromptForMissingPermissions(viewModel) - if (!viewModel.skipPromptsForAuthKeyLogin()) { - LaunchedEffect(state) { - if (state == Ipn.State.Running && !isAndroidTV()) { - viewModel.checkIfTaildropDirectorySelected() - } - } - } - if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) } @@ -259,25 +253,6 @@ fun MainView( { viewModel.showVPNPermissionLauncherIfUnauthorized() }) } } - - showDirectoryPickerInterstitial.let { show -> - if (show) { - AppTheme { - AlertDialog( - onDismissRequest = { viewModel.showDirectoryPickerLauncher() }, - title = { - Text(text = stringResource(id = R.string.taildrop_directory_picker_title)) - }, - text = { TaildropDirectoryPickerPrompt() }, - confirmButton = { - PrimaryActionButton(onClick = { viewModel.showDirectoryPickerLauncher() }) { - Text( - text = stringResource(id = R.string.taildrop_directory_picker_button)) - } - }) - } - } - } } currentPingDevice?.let { _ -> ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { @@ -869,8 +844,8 @@ fun Search( @Preview @Composable fun MainViewPreview() { - val vpnViewModel = VpnViewModel(App.get()) - val vm = MainViewModel(vpnViewModel) + val appViewModel = AppViewModel(App.get(), ShareFileHelper.taildropPrompt) + val vm = MainViewModel(appViewModel) MainView( {}, @@ -880,5 +855,6 @@ fun MainViewPreview() { onNavigateToExitNodes = {}, onNavigateToHealth = {}, onNavigateToSearch = {}), - vm) + vm, + appViewModel) } 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..973979e0f8 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) @@ -106,19 +167,20 @@ object ShareFileHelper : libtailscale.ShareFileHelper { destFile = targetDir.createFile("application/octet-stream", finalTargetName) - ?: throw IOException("Failed to create new file with name: $finalTargetName") + ?: throw IOException("Failed to create destination file: $finalTargetName") 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("Failed to open output stream for $finalTargetName") + } ?: throw IOException("Failed to open input stream from $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 a5d73929c6..12fe8b8386 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -98e8c99c256a5aeaa13725d2e43fdd7f465ba200 +98e8c99c256a5aeaa13725d2e43fdd7f465ba200 \ No newline at end of file diff --git a/libtailscale/backend.go b/libtailscale/backend.go index e8dbc4c37d..54554420ce 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()) @@ -266,7 +249,7 @@ func (a *App) runBackendOnce(ctx context.Context) error { func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, settings settingsFunc) (*backend, error) { - sys := new(tsd.System) + sys := tsd.NewSystem() sys.Set(store) logf := logger.RusagePrefixLog(log.Printf) @@ -336,8 +319,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 { @@ -367,18 +354,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 56636987fe..04ade23a95 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -121,6 +121,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. @@ -178,7 +180,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 @@ -217,7 +219,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)