From e68e64014ea0f9ee4ccc11d042119e0413accf5b Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:44:22 -0700 Subject: [PATCH] android: defer taildrop selector until first taildrop attempt (#684) Move Taildrop directory selector out of onboarding -Listen for Taildrop, and show selector if a directory has not been set Remove LocalBackend re-initialization -This is no longer necessary since the directory is set in FileOps Updates tailscale/corp#29211 Signed-off-by: kari-ts --- .../src/main/java/com/tailscale/ipn/App.kt | 95 +++----------- .../java/com/tailscale/ipn/MainActivity.kt | 92 +++++++++----- .../tailscale/ipn/TaildropDirectoryStore.kt | 8 -- .../com/tailscale/ipn/ui/view/MainView.kt | 70 ++--------- .../com/tailscale/ipn/ui/view/SettingsView.kt | 6 +- .../ipn/ui/viewModel/AppViewModel.kt | 119 ++++++++++++++++++ .../ipn/ui/viewModel/MainViewModel.kt | 87 ++----------- .../com/tailscale/ipn/util/ShareFileHelper.kt | 116 ++++++++++------- libtailscale/backend.go | 27 +--- libtailscale/callbacks.go | 3 - libtailscale/interfaces.go | 4 - libtailscale/tailscale.go | 7 +- 12 files changed, 284 insertions(+), 350 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 67a0a62..7e4e514 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -1,7 +1,6 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn - import android.Manifest import android.app.Application import android.app.Notification @@ -33,8 +32,8 @@ import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.notifier.HealthNotifier import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.viewModel.VpnViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory +import com.tailscale.ipn.ui.viewModel.AppViewModel +import com.tailscale.ipn.ui.viewModel.AppViewModelFactory import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog @@ -53,17 +52,14 @@ import java.io.IOException import java.net.NetworkInterface import java.security.GeneralSecurityException import java.util.Locale - class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - companion object { private const val FILE_CHANNEL_ID = "tailscale-files" // Key to store the SAF URI in EncryptedSharedPreferences. private val PREF_KEY_SAF_URI = "saf_directory_uri" private const val TAG = "App" private lateinit var appInstance: App - /** * Initializes the app (if necessary) and returns the singleton app instance. Always use this * function to obtain an App reference to make sure the app initializes. @@ -74,45 +70,33 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { return appInstance } } - val dns = DnsConfig() private lateinit var connectivityManager: ConnectivityManager private lateinit var mdmChangeReceiver: MDMSettingsChangedReceiver private lateinit var app: libtailscale.Application - override val viewModelStore: ViewModelStore get() = appViewModelStore - private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() } - var healthNotifier: HealthNotifier? = null - override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString - override fun getInstallSource(): String = AppSourceChecker.getInstallSource(this) - override fun shouldUseGoogleDNSFallback(): Boolean = BuildConfig.USE_GOOGLE_DNS_FALLBACK - override fun log(s: String, s1: String) { Log.d(s, s1) } - fun getLibtailscaleApp(): libtailscale.Application { if (!isInitialized) { initOnce() // Calls the synchronized initialization logic } return app } - override fun onCreate() { super.onCreate() appInstance = this setUnprotectedInstance(this) - mdmChangeReceiver = MDMSettingsChangedReceiver() val filter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) registerReceiver(mdmChangeReceiver, filter) - createNotificationChannel( STATUS_CHANNEL_ID, getString(R.string.vpn_status), @@ -129,7 +113,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { getString(R.string.health_channel_description), NotificationManagerCompat.IMPORTANCE_HIGH) } - override fun onTerminate() { super.onTerminate() Notifier.stop() @@ -138,19 +121,15 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { viewModelStore.clear() unregisterReceiver(mdmChangeReceiver) } - @Volatile private var isInitialized = false - @Synchronized private fun initOnce() { if (isInitialized) { return } - initializeApp() isInitialized = true } - private fun initializeApp() { // Check if a directory URI has already been stored. val storedUri = getStoredDirectoryUri() @@ -166,7 +145,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { applicationScope.launch { val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager MDMSettings.update(get(), rm) - Notifier.state.collect { _ -> combine(Notifier.state, MDMSettings.forceEnabled.flow, Notifier.prefs, Notifier.netmap) { state, @@ -184,11 +162,9 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { if (state == Ipn.State.Stopped) { notifyStatus(vpnRunning = false, hideDisconnectAction = hideDisconnectAction.value) } - val vpnRunning = state == Ipn.State.Starting || state == Ipn.State.Running updateConnStatus(ableToStartVPN) QuickToggleService.setVPNRunning(vpnRunning) - // Update notification status when VPN is running if (vpnRunning) { notifyStatus( @@ -205,21 +181,22 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { TSLog.init(this) FeatureFlags.initialize(mapOf("enable_new_search" to true)) } - /** * Called when a SAF directory URI is available (either already stored or chosen). We must restart * Tailscale because directFileRoot must be set before LocalBackend starts being used. */ fun startLibtailscale(directFileRoot: String) { - ShareFileHelper.init(this, directFileRoot) app = Libtailscale.start(this.filesDir.absolutePath, directFileRoot, this) + ShareFileHelper.init(this, app, directFileRoot, applicationScope) Request.setApp(app) Notifier.setApp(app) Notifier.start(applicationScope) } private fun initViewModels() { - vpnViewModel = ViewModelProvider(this, VpnViewModelFactory(this)).get(VpnViewModel::class.java) + appViewModel = + ViewModelProvider(this, AppViewModelFactory(this, ShareFileHelper.observeTaildropPrompt())) + .get(AppViewModel::class.java) } fun setWantRunning(wantRunning: Boolean, onSuccess: (() -> Unit)? = null) { @@ -233,14 +210,12 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { Client(applicationScope) .editPrefs(Ipn.MaskedPrefs().apply { WantRunning = wantRunning }, callback) } - // encryptToPref a byte array of data using the Jetpack Security // library and writes it to a global encrypted preference store. @Throws(IOException::class, GeneralSecurityException::class) override fun encryptToPref(prefKey: String?, plaintext: String?) { getEncryptedPrefs().edit().putString(prefKey, plaintext).commit() } - // decryptFromPref decrypts a encrypted preference using the Jetpack Security // library and returns the plaintext. @Throws(IOException::class, GeneralSecurityException::class) @@ -250,18 +225,18 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { override fun getStateStoreKeysJSON(): String { val prefix = "statestore-" - val keys = getEncryptedPrefs() - .getAll() - .keys - .filter { it.startsWith(prefix) } - .map { it.removePrefix(prefix) } + val keys = + getEncryptedPrefs() + .getAll() + .keys + .filter { it.startsWith(prefix) } + .map { it.removePrefix(prefix) } return org.json.JSONArray(keys).toString() } @Throws(IOException::class, GeneralSecurityException::class) fun getEncryptedPrefs(): SharedPreferences { val key = MasterKey.Builder(this).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - return EncryptedSharedPreferences.create( this, "secret_shared_prefs", @@ -269,12 +244,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) } - fun getStoredDirectoryUri(): Uri? { val uriString = getEncryptedPrefs().getString(PREF_KEY_SAF_URI, null) return uriString?.let { Uri.parse(it) } } - /* * setAbleToStartVPN remembers whether or not we're able to start the VPN * by storing this in a shared preference. This allows us to check this @@ -285,7 +258,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { QuickToggleService.updateTile() TSLog.d("App", "Set Tile Ready: $ableToStartVPN") } - override fun getModelName(): String { val manu = Build.MANUFACTURER var model = Build.MODEL @@ -296,17 +268,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } return "$manu $model" } - override fun getOSVersion(): String = Build.VERSION.RELEASE - override fun isChromeOS(): Boolean { return packageManager.hasSystemFeature("android.hardware.type.pc") } - override fun getInterfacesAsString(): String { val interfaces: ArrayList = java.util.Collections.list(NetworkInterface.getNetworkInterfaces()) - val sb = StringBuilder() for (nif in interfaces) { try { @@ -322,7 +290,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { nif.isLoopback, nif.isPointToPoint, nif.supportsMulticast())) - for (ia in nif.interfaceAddresses) { val parts = ia.toString().split("/", limit = 0) if (parts.size > 1) { @@ -334,16 +301,13 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } sb.append("\n") } - return sb.toString() } - @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyBooleanValue(key: String): Boolean { return getSyspolicyStringValue(key) == "true" } - @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyStringValue(key: String): String { @@ -353,7 +317,6 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { } return setting.value?.toString() ?: "" } - @Throws( IOException::class, GeneralSecurityException::class, MDMSettings.NoSuchKeyException::class) override fun getSyspolicyStringArrayJSONValue(key: String): String { @@ -369,12 +332,10 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { throw MDMSettings.NoSuchKeyException() } } - fun notifyPolicyChanged() { app.notifyPolicyChanged() } } - /** * UninitializedApp contains all of the methods of App that can be used without having to initialize * the Go backend. This is useful when you want to access functions on the App without creating side @@ -383,30 +344,24 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner { open class UninitializedApp : Application() { companion object { const val TAG = "UninitializedApp" - const val STATUS_NOTIFICATION_ID = 1 const val STATUS_EXIT_NODE_FAILURE_NOTIFICATION_ID = 2 const val STATUS_CHANNEL_ID = "tailscale-status" - // Key for shared preference that tracks whether or not we're able to start // the VPN (i.e. we're logged in and machine is authorized). private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN" - private const val DISALLOWED_APPS_KEY = "disallowedApps" - // File for shared preferences that are not encrypted. private const val UNENCRYPTED_PREFERENCES = "unencrypted" - private lateinit var appInstance: UninitializedApp lateinit var notificationManager: NotificationManagerCompat - lateinit var vpnViewModel: VpnViewModel + lateinit var appViewModel: AppViewModel @JvmStatic fun get(): UninitializedApp { return appInstance } - /** * Return the name of the active (but not the selected/prior one) exit node based on the * provided [Ipn.Prefs] and [Netmap.NetworkMap]. @@ -419,24 +374,19 @@ open class UninitializedApp : Application() { } } } - protected fun setUnprotectedInstance(instance: UninitializedApp) { appInstance = instance } - protected fun setAbleToStartVPN(rdy: Boolean) { getUnencryptedPrefs().edit().putBoolean(ABLE_TO_START_VPN_KEY, rdy).apply() } - /** This function can be called without initializing the App. */ fun isAbleToStartVPN(): Boolean { return getUnencryptedPrefs().getBoolean(ABLE_TO_START_VPN_KEY, false) } - private fun getUnencryptedPrefs(): SharedPreferences { return getSharedPreferences(UNENCRYPTED_PREFERENCES, MODE_PRIVATE) } - fun startVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_START_VPN } // FLAG_UPDATE_CURRENT ensures that if the intent is already pending, the existing intent will @@ -449,7 +399,6 @@ open class UninitializedApp : Application() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE // FLAG_IMMUTABLE for Android 12+ ) - try { pendingIntent.send() } catch (foregroundServiceStartException: IllegalStateException) { @@ -462,7 +411,6 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "startVPN hit exception: $e") } } - fun stopVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_STOP_VPN } try { @@ -473,7 +421,6 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "stopVPN hit exception in startService(): $e") } } - fun restartVPN() { val intent = Intent(this, IPNService::class.java).apply { action = IPNService.ACTION_RESTART_VPN } @@ -485,14 +432,12 @@ open class UninitializedApp : Application() { TSLog.e(TAG, "restartVPN hit exception in startService(): $e") } } - fun createNotificationChannel(id: String, name: String, description: String, importance: Int) { val channel = NotificationChannel(id, name, importance) channel.description = description notificationManager = NotificationManagerCompat.from(this) notificationManager.createNotificationChannel(channel) } - fun notifyStatus( vpnRunning: Boolean, hideDisconnectAction: Boolean, @@ -500,7 +445,6 @@ open class UninitializedApp : Application() { ) { notifyStatus(buildStatusNotification(vpnRunning, hideDisconnectAction, exitNodeName)) } - fun notifyStatus(notification: Notification) { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { @@ -515,7 +459,6 @@ open class UninitializedApp : Application() { } notificationManager.notify(STATUS_NOTIFICATION_ID, notification) } - fun buildStatusNotification( vpnRunning: Boolean, hideDisconnectAction: Boolean, @@ -537,7 +480,6 @@ open class UninitializedApp : Application() { 0, buttonIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val intent = Intent(this, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK @@ -545,7 +487,6 @@ open class UninitializedApp : Application() { val pendingIntent: PendingIntent = PendingIntent.getActivity( this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - val builder = NotificationCompat.Builder(this, STATUS_CHANNEL_ID) .setSmallIcon(icon) @@ -563,18 +504,14 @@ open class UninitializedApp : Application() { } return builder.build() } - fun updateUserDisallowedPackageNames(packageNames: List) { if (packageNames.any { it.isEmpty() }) { TSLog.e(TAG, "updateUserDisallowedPackageNames called with empty packageName(s)") return } - getUnencryptedPrefs().edit().putStringSet(DISALLOWED_APPS_KEY, packageNames.toSet()).apply() - this.restartVPN() } - fun disallowedPackageNames(): List { val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() @@ -587,8 +524,8 @@ open class UninitializedApp : Application() { return builtInDisallowedPackageNames + userDisallowed } - fun getAppScopedViewModel(): VpnViewModel { - return vpnViewModel + fun getAppScopedViewModel(): AppViewModel { + return appViewModel } val builtInDisallowedPackageNames: List = @@ -616,4 +553,4 @@ open class UninitializedApp : Application() { // Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128 "com.google.android.apps.scone", ) -} +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index dbcbc3f..28ed413 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,38 @@ import com.tailscale.ipn.ui.view.MullvadInfoView import com.tailscale.ipn.ui.view.NotificationsView import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView +import com.tailscale.ipn.ui.view.PrimaryActionButton import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SubnetRoutingView import com.tailscale.ipn.ui.view.TaildropDirView +import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModelFactory import com.tailscale.ipn.ui.viewModel.PermissionsViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav -import com.tailscale.ipn.ui.viewModel.VpnViewModel +import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import libtailscale.Libtailscale class MainActivity : ComponentActivity() { private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher - private val viewModel: MainViewModel by lazy { - val app = App.get() - vpnViewModel = app.getAppScopedViewModel() - ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java) - } - private lateinit var vpnViewModel: VpnViewModel + private lateinit var appViewModel: AppViewModel + private lateinit var viewModel: MainViewModel + val permissionsViewModel: PermissionsViewModel by viewModels() companion object { @@ -119,7 +126,6 @@ class MainActivity : ComponentActivity() { return (resources.configuration.screenLayout and SCREENLAYOUT_SIZE_MASK) >= SCREENLAYOUT_SIZE_LARGE } - // The loginQRCode is used to track whether or not we should be rendering a QR code // to the user. This is used only on TV platforms with no browser in lieu of // simply opening the URL. This should be consumed once it has been handled. @@ -132,29 +138,27 @@ class MainActivity : ComponentActivity() { // grab app to make sure it initializes App.get() - vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java) + appViewModel = (application as App).getAppScopedViewModel() + viewModel = + ViewModelProvider(this, MainViewModelFactory(appViewModel)).get(MainViewModel::class.java) val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager MDMSettings.update(App.get(), rm) - if (MDMSettings.onboardingFlow.flow.value.value == ShowHide.Hide || MDMSettings.authKey.flow.value.value != null) { setIntroScreenViewed(true) } - // (jonathan) TODO: Force the app to be portrait on small screens until we have // proper landscape layout support if (!isLandscapeCapable()) { requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT } - installSplashScreen() - vpnPermissionLauncher = registerForActivityResult(VpnPermissionContract()) { granted -> if (granted) { TSLog.d("VpnPermission", "VPN permission granted") - vpnViewModel.setVpnPrepared(true) + appViewModel.setVpnPrepared(true) App.get().startVPN() } else { if (isAnotherVpnActive(this)) { @@ -162,7 +166,7 @@ class MainActivity : ComponentActivity() { showOtherVPNConflictDialog() } else { TSLog.d("VpnPermission", "Permission was denied by the user") - vpnViewModel.setVpnPrepared(false) + appViewModel.setVpnPrepared(false) AlertDialog.Builder(this) .setTitle(R.string.vpn_permission_needed) @@ -176,7 +180,6 @@ class MainActivity : ComponentActivity() { } } viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) - val directoryPickerLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> if (uri != null) { @@ -188,7 +191,6 @@ class MainActivity : ComponentActivity() { } catch (e: SecurityException) { TSLog.e("MainActivity", "Failed to persist permissions: $e") } - // Check if write permission is actually granted. val writePermission = this.checkUriPermission( @@ -198,9 +200,10 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch(Dispatchers.IO) { try { - Libtailscale.setDirectFileRoot(uri.toString()) TaildropDirectoryStore.saveFileDirectory(uri) permissionsViewModel.refreshCurrentDir() + ShareFileHelper.notifyDirectoryReady() + ShareFileHelper.setUri(uri.toString()) } catch (e: Exception) { TSLog.e("MainActivity", "Failed to set Taildrop root: $e") } @@ -214,14 +217,40 @@ class MainActivity : ComponentActivity() { } else { TSLog.d( "MainActivity", "Taildrop directory not saved. Will fall back to internal storage.") - // Fall back to internal storage. } } - viewModel.setDirectoryPickerLauncher(directoryPickerLauncher) + appViewModel.directoryPickerLauncher = directoryPickerLauncher setContent { + var showDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { appViewModel.triggerDirectoryPicker.collect { showDialog = true } } + + if (showDialog) { + AppTheme { + AlertDialog( + onDismissRequest = { + showDialog = false + appViewModel.directoryPickerLauncher?.launch(null) + }, + title = { + Text(text = stringResource(id = R.string.taildrop_directory_picker_title)) + }, + text = { TaildropDirectoryPickerPrompt() }, + confirmButton = { + PrimaryActionButton( + onClick = { + showDialog = false + appViewModel.directoryPickerLauncher?.launch(null) + }) { + Text(text = stringResource(id = R.string.taildrop_directory_picker_button)) + } + }) + } + } + navController = rememberNavController() AppTheme { @@ -257,7 +286,6 @@ class MainActivity : ComponentActivity() { fun backTo(route: String): () -> Unit = { navController.popBackStack(route = route, inclusive = false) } - val mainViewNav = MainViewNavigation( onNavigateToSettings = { navController.navigate("settings") }, @@ -270,7 +298,6 @@ class MainActivity : ComponentActivity() { viewModel.enableSearchAutoFocus() navController.navigate("search") }) - val settingsNav = SettingsNav( onNavigateToBugReport = { navController.navigate("bugReport") }, @@ -285,7 +312,6 @@ class MainActivity : ComponentActivity() { onNavigateToPermissions = { navController.navigate("permissions") }, onBackToSettings = backTo("settings"), onNavigateBackHome = backTo("main")) - val exitNodePickerNav = ExitNodePickerNav( onNavigateBackHome = { @@ -297,7 +323,6 @@ class MainActivity : ComponentActivity() { onNavigateBackToMullvad = backTo("mullvad"), onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) - val userSwitcherNav = UserSwitcherNav( backToSettings = backTo("settings"), @@ -308,7 +333,11 @@ class MainActivity : ComponentActivity() { onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") }) composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { - MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel) + MainView( + loginAtUrl = ::login, + navigation = mainViewNav, + viewModel = viewModel, + appViewModel = appViewModel) } composable("search") { val autoFocus = viewModel.autoFocusSearch @@ -318,7 +347,9 @@ class MainActivity : ComponentActivity() { onNavigateBack = { navController.popBackStack() }, autoFocus = autoFocus) } - composable("settings") { SettingsView(settingsNav) } + composable("settings") { + SettingsView(settingsNav = settingsNav, appViewModel = appViewModel) + } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("health") { HealthView(backTo("main")) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } @@ -378,7 +409,6 @@ class MainActivity : ComponentActivity() { } } } - // Login actions are app wide. If we are told about a browse-to-url, we should render it // over whatever screen we happen to be on. loginQRCode.collectAsState().value?.let { @@ -401,7 +431,6 @@ class MainActivity : ComponentActivity() { } } } - // Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog. lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } } } @@ -422,7 +451,6 @@ class MainActivity : ComponentActivity() { fun isAnotherVpnActive(context: Context): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetwork = connectivityManager.activeNetwork if (activeNetwork != null) { val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) @@ -433,7 +461,6 @@ class MainActivity : ComponentActivity() { } return false } - // Returns true if we should render a QR code instead of launching a browser // for login requests private fun useQRCodeLogin(): Boolean { @@ -449,7 +476,6 @@ class MainActivity : ComponentActivity() { if (this::navController.isInitialized) { val previousEntry = navController.previousBackStackEntry TSLog.d("MainActivity", "onNewIntent: previousBackStackEntry = $previousEntry") - if (previousEntry != null) { navController.popBackStack(route = "main", inclusive = false) } else { @@ -478,7 +504,6 @@ class MainActivity : ComponentActivity() { putExtra(START_AT_ROOT, true) } startActivity(intent) - // Cancel coroutine once we've logged in this@launch.cancel() } @@ -487,7 +512,6 @@ class MainActivity : ComponentActivity() { TSLog.e(TAG, "Login: failed to start MainActivity: $e") } } - val url = urlString.toUri() try { val customTabsIntent = CustomTabsIntent.Builder().build() diff --git a/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt b/android/src/main/java/com/tailscale/ipn/TaildropDirectoryStore.kt index be02c95..c168d7d 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 0a848a4..9b14cd9 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 @@ -1,6 +1,5 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailscale.ipn.ui.view import android.os.Build @@ -32,7 +31,6 @@ import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -87,7 +85,6 @@ import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Tailcfg -import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.customErrorContainer import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.errorButton @@ -109,10 +106,11 @@ import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState import com.tailscale.ipn.ui.viewModel.MainViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.util.FeatureFlags +import kotlinx.coroutines.flow.emptyFlow // Navigation actions for the MainView data class MainViewNavigation( @@ -129,6 +127,7 @@ fun MainView( loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewModel: MainViewModel, + appViewModel: AppViewModel ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState() @@ -151,12 +150,9 @@ fun MainView( val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) - val showDirectoryPickerInterstitial by - viewModel.showDirectoryPickerInterstitial.collectAsState() // Hide the header only on Android TV when the user needs to login val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) - ListItem( colors = MaterialTheme.colorScheme.surfaceContainerListItem, leadingContent = { @@ -212,30 +208,19 @@ fun MainView( } } }) - when (state) { Ipn.State.Running -> { viewModel.maybeRequestVpnPermission() LaunchVpnPermissionIfNeeded(viewModel) PromptForMissingPermissions(viewModel) - if (!viewModel.skipPromptsForAuthKeyLogin()) { - LaunchedEffect(state) { - if (state == Ipn.State.Running && !isAndroidTV()) { - viewModel.checkIfTaildropDirectorySelected() - } - } - } - if (showKeyExpiry) { ExpiryNotification(netmap = netmap, action = { viewModel.login() }) } - if (showExitNodePicker.value == ShowHide.Show) { ExitNodeStatus( navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) } - PeerList( viewModel = viewModel, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, @@ -259,25 +244,6 @@ fun MainView( { viewModel.showVPNPermissionLauncherIfUnauthorized() }) } } - - showDirectoryPickerInterstitial.let { show -> - if (show) { - AppTheme { - AlertDialog( - onDismissRequest = { viewModel.showDirectoryPickerLauncher() }, - title = { - Text(text = stringResource(id = R.string.taildrop_directory_picker_title)) - }, - text = { TaildropDirectoryPickerPrompt() }, - confirmButton = { - PrimaryActionButton(onClick = { viewModel.showDirectoryPickerLauncher() }) { - Text( - text = stringResource(id = R.string.taildrop_directory_picker_button)) - } - }) - } - } - } } currentPingDevice?.let { _ -> ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { @@ -291,7 +257,6 @@ fun MainView( @Composable fun TaildropDirectoryPickerPrompt() { val uriHandler = LocalUriHandler.current - Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) { Text(text = stringResource(id = R.string.taildrop_directory_picker_body)) Text( @@ -306,10 +271,8 @@ fun TaildropDirectoryPickerPrompt() { fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { val lifecycleOwner = LocalLifecycleOwner.current val shouldRequest by viewModel.requestVpnPermission.collectAsState() - LaunchedEffect(shouldRequest) { if (!shouldRequest) return@LaunchedEffect - // Defer showing permission launcher until activity is resumed to avoid silent RESULT_CANCELED lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.showVPNPermissionLauncherIfUnauthorized() @@ -322,19 +285,14 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { val nodeState by viewModel.nodeState.collectAsState() val maybePrefs by viewModel.prefs.collectAsState() val netmap by viewModel.netmap.collectAsState() - // There's nothing to render if we haven't loaded the prefs yet val prefs = maybePrefs ?: return - // The activeExitNode is the source of truth. The selectedExitNode is only relevant if we // don't have an active node. val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID - val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } } val name = exitNodePeer?.exitNodeName - val managedByOrganization by viewModel.managedByOrganization.collectAsState() - Box( modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surfaceContainer)) { @@ -359,7 +317,6 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { } } } - Box( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp) @@ -597,7 +554,6 @@ fun PeerList( val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val showNoResults = remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value - val netmap = viewModel.netmap.collectAsState() val focusManager = LocalFocusManager.current var isSearchFocussed by remember { mutableStateOf(false) } @@ -606,7 +562,6 @@ fun PeerList( val localClipboardManager = LocalClipboardManager.current // Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375) val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - Column(modifier = Modifier.fillMaxSize()) { if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { Search(onSearchBarClick) @@ -653,7 +608,6 @@ fun PeerList( } } } - // Peers display LazyColumn( modifier = @@ -661,7 +615,6 @@ fun PeerList( .weight(1f) // LazyColumn gets the remaining vertical space .onFocusChanged { isListFocussed = it.isFocused } .background(color = MaterialTheme.colorScheme.surface)) { - // Handle case when no results are found if (showNoResults) { item { @@ -677,7 +630,6 @@ fun PeerList( fontWeight = FontWeight.Light) } } - // Iterate over peer sets to display them var first = true peerList.forEach { peerSet -> @@ -685,13 +637,11 @@ fun PeerList( item(key = "user_divider_${peerSet.user?.ID ?: 0L}") { Lists.ItemDivider() } } first = false - if (isAndroidTV()) { item { NodesSectionHeader(peerSet = peerSet) } } else { stickyHeader { NodesSectionHeader(peerSet = peerSet) } } - itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> ListItem( modifier = @@ -758,7 +708,6 @@ fun PeerList( @Composable fun NodesSectionHeader(peerSet: PeerSet) { Spacer(Modifier.height(16.dp).fillMaxSize().background(color = MaterialTheme.colorScheme.surface)) - Lists.LargeTitle( peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), bottomPadding = 8.dp, @@ -770,7 +719,6 @@ fun NodesSectionHeader(peerSet: PeerSet) { @Composable fun ExpiryNotification(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { if (netmap == null) return - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) { Box( modifier = @@ -801,7 +749,6 @@ fun PromptForMissingPermissions(viewModel: MainViewModel) { if (viewModel.skipPromptsForAuthKeyLogin()) { return } - Permissions.prompt.forEach { (permission, state) -> ErrorDialog( title = permission.title, @@ -820,7 +767,6 @@ fun Search( ) { // Prevent multiple taps var isNavigating by remember { mutableStateOf(false) } - Box( modifier = Modifier.fillMaxWidth() @@ -851,7 +797,6 @@ fun Search( Modifier.padding(start = 0.dp) // Optional start padding for alignment ) Spacer(modifier = Modifier.width(4.dp)) - // Placeholder Text Text( text = stringResource(R.string.search_ellipsis), @@ -869,9 +814,9 @@ fun Search( @Preview @Composable fun MainViewPreview() { - val vpnViewModel = VpnViewModel(App.get()) - val vm = MainViewModel(vpnViewModel) - + val fakePrompt = emptyFlow() + val appViewModel = AppViewModel(App.get(), fakePrompt) + val vm = MainViewModel(appViewModel) MainView( {}, MainViewNavigation( @@ -880,5 +825,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 e29e988..c98f18c 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 0000000..685c50d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/AppViewModel.kt @@ -0,0 +1,119 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import android.app.Application +import android.net.Uri +import android.net.VpnService +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.tailscale.ipn.App +import com.tailscale.ipn.util.ShareFileHelper +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class AppViewModelFactory(val application: Application, private val taildropPrompt: Flow) : + 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 _triggerDirectoryPicker = MutableSharedFlow(extraBufferCapacity = 1) + val triggerDirectoryPicker: SharedFlow = _triggerDirectoryPicker + val TAG = "AppViewModel" + + init { + observeIncomingTaildrop() + prepareVpn() + } + + private fun observeIncomingTaildrop() { + viewModelScope.launch { + taildropPrompt.collect { + TSLog.d(TAG, "Taildrop event received, checking directory") + checkIfTaildropDirectorySelected() + } + } + } + + fun requestDirectoryPicker() { + _triggerDirectoryPicker.tryEmit(Unit) + } + + private fun prepareVpn() { + // Check if the user has granted permission yet. + if (!vpnPrepared.value) { + val vpnIntent = VpnService.prepare(getApplication()) + if (vpnIntent != null) { + setVpnPrepared(false) + Log.d(TAG, "VpnService.prepare returned non-null intent") + } else { + setVpnPrepared(true) + Log.d(TAG, "VpnService.prepare returned null intent, VPN is already prepared") + } + } + } + + fun checkIfTaildropDirectorySelected() { + val app = App.get() + val storedUri = app.getStoredDirectoryUri() + if (ShareFileHelper.hasValidTaildropDir()) { + return + } + + val documentFile = storedUri?.let { DocumentFile.fromTreeUri(app, it) } + if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) { + TSLog.d( + "MainViewModel", + "Stored directory URI is invalid or inaccessible; launching directory picker.") + viewModelScope.launch { requestDirectoryPicker() } + } else { + TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") + } + } + + fun setVpnActive(isActive: Boolean) { + _vpnActive.value = isActive + } + + fun setVpnPrepared(isPrepared: Boolean) { + _vpnPrepared.value = isPrepared + } +} \ No newline at end of file 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 edb41eb..7becdeb 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 @@ -1,8 +1,6 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailscale.ipn.ui.viewModel - import android.content.Intent import android.net.Uri import android.net.VpnService @@ -39,112 +37,89 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import java.time.Duration -class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelProvider.Factory { +class MainViewModelFactory(private val appViewModel: AppViewModel) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun 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)) - // The expected state of the VPN toggle private val _vpnToggleState = MutableStateFlow(false) val vpnToggleState: StateFlow = _vpnToggleState - // Keeps track of whether a toggle operation is in progress. This ensures that toggleVpn cannot be // invoked until the current operation is complete. var isToggleInProgress = MutableStateFlow(false) - // Permission to prepare VPN private var vpnPermissionLauncher: ActivityResultLauncher? = null 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 - // The list of peers private val _searchViewPeers = MutableStateFlow>(emptyList()) val searchViewPeers: StateFlow> = _searchViewPeers - // The current state of the IPN for determining view visibility val ipnState = Notifier.state - // The active search term for filtering peers private val _searchTerm = MutableStateFlow("") val searchTerm: StateFlow = _searchTerm - var autoFocusSearch by mutableStateOf(true) private set - // True if we should render the key expiry bannder val showExpiry: StateFlow = MutableStateFlow(false) - // The peer for which the dropdown menu is currently expanded. Null if no menu is expanded var expandedMenuPeer: StateFlow = MutableStateFlow(null) 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 // Icon displayed in the button to present the health view val healthIcon: StateFlow = MutableStateFlow(null) - fun updateSearchTerm(term: String) { _searchTerm.value = term } - fun hidePeerDropdownMenu() { expandedMenuPeer.set(null) } - fun copyIpAddress(peer: Tailcfg.Node, clipboardManager: ClipboardManager) { clipboardManager.setText(AnnotatedString(peer.primaryIPv4Address ?: "")) } - fun startPing(peer: Tailcfg.Node) { this.pingViewModel.startPing(peer) } - fun onPingDismissal() { this.pingViewModel.handleDismissal() } - // Returns true if we should skip all of the user-interactive permissions prompts // (with the exception of the VPN permission prompt) fun skipPromptsForAuthKeyLogin(): Boolean { val v = MDMSettings.authKey.flow.value.value return v != null && v != "" } - private val peerCategorizer = PeerCategorizer() - init { viewModelScope.launch { var previousState: State? = null - combine(Notifier.state, isVpnActive) { state, active -> state to active } .collect { (currentState, active) -> // Determine the correct state resource string stateRes.set(userStringRes(currentState, previousState, active)) - // Determine if the VPN toggle should be on val isOn = when { @@ -153,15 +128,12 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { previousState == State.NoState && currentState == State.Starting -> true else -> false } - // Update the VPN toggle state _vpnToggleState.value = isOn - // Update the previous state previousState = currentState } } - viewModelScope.launch { _searchTerm.debounce(250L).collect { term -> // run the search as a background task @@ -173,7 +145,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } } - viewModelScope.launch { Notifier.netmap.collect { it -> it?.let { netmap -> @@ -184,7 +155,6 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { _peers.value = peerCategorizer.peerSets _searchViewPeers.value = filteredPeers } - if (netmap.SelfNode.keyDoesNotExpire) { showExpiry.set(false) return@let @@ -199,57 +169,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } } - viewModelScope.launch { App.get().healthNotifier?.currentIcon?.collect { icon -> healthIcon.set(icon) } } } - fun maybeRequestVpnPermission() { _requestVpnPermission.value = true } - fun showVPNPermissionLauncherIfUnauthorized() { val vpnIntent = VpnService.prepare(App.get()) TSLog.d("VpnPermissions", "vpnIntent=$vpnIntent") if (vpnIntent != null) { vpnPermissionLauncher?.launch(vpnIntent) } else { - vpnViewModel.setVpnPrepared(true) + appViewModel.setVpnPrepared(true) startVPN() } _requestVpnPermission.value = false // reset } - fun showDirectoryPickerLauncher() { - _showDirectoryPickerInterstitial.set(false) - directoryPickerLauncher?.launch(null) - } - - fun checkIfTaildropDirectorySelected() { - if (skipPromptsForAuthKeyLogin() || AndroidTVUtil.isAndroidTV()) { - return - } - - val app = App.get() - val storedUri = app.getStoredDirectoryUri() - if (storedUri == null) { - // No stored URI, so launch the directory picker. - _showDirectoryPickerInterstitial.set(true) - return - } - - val documentFile = DocumentFile.fromTreeUri(app, storedUri) - if (documentFile == null || !documentFile.exists() || !documentFile.canWrite()) { - TSLog.d( - "MainViewModel", - "Stored directory URI is invalid or inaccessible; launching directory picker.") - _showDirectoryPickerInterstitial.set(true) - } else { - TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") - } - } - fun toggleVpn(desiredState: Boolean) { if (isToggleInProgress.value) { // Prevent toggling while a previous toggle is in progress @@ -257,16 +195,13 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } viewModelScope.launch { - checkIfTaildropDirectorySelected() isToggleInProgress.value = true try { val currentState = Notifier.state.value - val isPrepared = vpnViewModel.vpnPrepared.value if (desiredState) { // User wants to turn ON the VPN when { - !isPrepared -> showVPNPermissionLauncherIfUnauthorized() currentState != Ipn.State.Running -> startVPN() } } else { @@ -280,27 +215,19 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } } } - fun searchPeers(searchTerm: String) { this.searchTerm.set(searchTerm) } - fun enableSearchAutoFocus() { autoFocusSearch = true } - fun disableSearchAutoFocus() { autoFocusSearch = false } - fun setVpnPermissionLauncher(launcher: ActivityResultLauncher) { // 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 { @@ -316,4 +243,4 @@ private fun userStringRes(currentState: State?, previousState: State?, vpnActive currentState == State.Running -> if (vpnActive) R.string.connected else R.string.placeholder else -> R.string.placeholder } -} +} \ No newline at end of file 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 a15636b..e467389 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -1,15 +1,20 @@ // Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause - package com.tailscale.ipn.util - import android.content.Context import android.net.Uri import android.os.ParcelFileDescriptor import android.provider.DocumentsContract import androidx.documentfile.provider.DocumentFile +import com.tailscale.ipn.TaildropDirectoryStore import com.tailscale.ipn.ui.util.InputStreamAdapter import com.tailscale.ipn.ui.util.OutputStreamAdapter +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import libtailscale.Libtailscale import org.json.JSONObject import java.io.FileOutputStream @@ -17,38 +22,80 @@ import java.io.IOException import java.io.OutputStream import java.util.UUID import java.util.concurrent.ConcurrentHashMap - data class SafFile(val fd: Int, val uri: String) object ShareFileHelper : libtailscale.ShareFileHelper { private var appContext: Context? = null + private var app: libtailscale.Application? = null private var savedUri: String? = null + private var scope: CoroutineScope? = null @JvmStatic - fun init(context: Context, uri: String) { + fun init(context: Context, app: libtailscale.Application, uri: String, appScope: CoroutineScope) { appContext = context.applicationContext + this.app = app savedUri = uri + scope = appScope Libtailscale.setShareFileHelper(this) + TSLog.d("ShareFileHelper", "init ShareFileHelper with savedUri: $savedUri") } // A simple data class that holds a SAF OutputStream along with its URI. data class SafStream(val uri: String, val stream: OutputStream) + val taildropPrompt = MutableSharedFlow(replay = 1) + + fun observeTaildropPrompt(): Flow = taildropPrompt + + @Volatile private var directoryReady: CompletableDeferred? = null + + fun hasValidTaildropDir(): Boolean { + val uri = TaildropDirectoryStore.loadSavedDir() + if (uri == null) return false + + // Only SAF tree URIs are supported + if (uri.scheme != "content") { + TSLog.w("ShareFileHelper", "Invalid URI scheme for taildrop dir: ${uri.scheme}") + return false + } + + val context = appContext ?: return false + val docFile = DocumentFile.fromTreeUri(context, uri) + + if (docFile == null || !docFile.exists() || !docFile.canWrite()) { + TSLog.w("ShareFileHelper", "Stored taildrop URI is invalid or inaccessible: $uri") + return false + } + + return true + } + + private suspend fun waitUntilTaildropDirReady() { + if (!hasValidTaildropDir()) { + if (directoryReady?.isActive != true) { + directoryReady = CompletableDeferred() + scope?.launch { taildropPrompt.emit(Unit) } + } + directoryReady?.await() + } + } + + fun notifyDirectoryReady() { + directoryReady?.takeIf { !it.isCompleted }?.complete(Unit) + } + // A helper function that opens or creates a SafStream for a given file. private fun openSafFileOutputStream(fileName: String): Pair { val context = appContext ?: return "" to null val dirUri = savedUri ?: return "" to null val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: return "" to null - val file = dir.findFile(fileName) ?: dir.createFile("application/octet-stream", fileName) ?: return "" to null - val os = context.contentResolver.openOutputStream(file.uri, "rw") return file.uri.toString() to os } - @Throws(IOException::class) private fun openWriterFD(fileName: String, offset: Long): Pair { val ctx = appContext ?: throw IOException("App context not initialized") @@ -60,20 +107,18 @@ object ShareFileHelper : libtailscale.ShareFileHelper { dir.findFile(fileName) ?: dir.createFile("application/octet-stream", fileName) ?: throw IOException("Failed to create file: $fileName") - val pfd = ctx.contentResolver.openFileDescriptor(file.uri, "rw") ?: throw IOException("Failed to open file descriptor for ${file.uri}") val fos = FileOutputStream(pfd.fileDescriptor) - if (offset != 0L) fos.channel.position(offset) else fos.channel.truncate(0) return file.uri.toString() to SeekableOutputStream(fos, pfd) } - private val currentUri = ConcurrentHashMap() @Throws(IOException::class) override fun openFileWriter(fileName: String, offset: Long): libtailscale.OutputStream { + runBlocking { waitUntilTaildropDirReady() } val (uri, stream) = openWriterFD(fileName, offset) if (stream == null) { throw IOException("Failed to open file writer for $fileName") @@ -84,22 +129,20 @@ object ShareFileHelper : libtailscale.ShareFileHelper { @Throws(IOException::class) override fun getFileURI(fileName: String): String { + runBlocking { waitUntilTaildropDirReady() } currentUri[fileName]?.let { return it } - val ctx = appContext ?: throw IOException("App context not initialized") val dirStr = savedUri ?: throw IOException("No saved directory URI") val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirStr)) ?: throw IOException("Invalid tree URI: $dirStr") - val file = dir.findFile(fileName) ?: throw IOException("File not found: $fileName") val uri = file.uri.toString() currentUri[fileName] = uri return uri } - @Throws(IOException::class) override fun renameFile(oldPath: String, targetName: String): String { val ctx = appContext ?: throw IOException("not initialized") @@ -108,7 +151,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val dir = DocumentFile.fromTreeUri(ctx, Uri.parse(dirUri)) ?: throw IOException("cannot open dir $dirUri") - + var finalName = targetName dir.findFile(finalName)?.let { existing -> if (lengthOfUri(ctx, existing.uri) == 0L) { @@ -117,7 +160,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { finalName = generateNewFilename(finalName) } } - + try { DocumentsContract.renameDocument(ctx.contentResolver, srcUri, finalName)?.also { newUri -> runCatching { ctx.contentResolver.delete(srcUri, null, null) } @@ -125,14 +168,14 @@ object ShareFileHelper : libtailscale.ShareFileHelper { return newUri.toString() } } catch (e: Exception) { - TSLog.w("renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") - + TSLog.w( + "renameFile", "renameDocument fallback triggered for $srcUri -> $finalName: ${e.message}") } - + val dest = dir.createFile("application/octet-stream", finalName) ?: throw IOException("createFile failed for $finalName") - + ctx.contentResolver.openInputStream(srcUri).use { inp -> ctx.contentResolver.openOutputStream(dest.uri, "w").use { out -> if (inp == null || out == null) { @@ -142,15 +185,13 @@ object ShareFileHelper : libtailscale.ShareFileHelper { inp.copyTo(out) } } - + ctx.contentResolver.delete(srcUri, null, null) cleanupPartials(dir, targetName) return dest.uri.toString() } - private fun lengthOfUri(ctx: Context, uri: Uri): Long = ctx.contentResolver.openAssetFileDescriptor(uri, "r").use { it?.length ?: -1 } - // delete any stray “.partial” files for this base name private fun cleanupPartials(dir: DocumentFile, base: String) { for (child in dir.listFiles()) { @@ -160,21 +201,18 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } } } - @Throws(IOException::class) override fun deleteFile(uri: String) { + runBlocking { waitUntilTaildropDirReady() } val ctx = appContext ?: throw IOException("DeleteFile: not initialized") - val uri = Uri.parse(uri) val doc = DocumentFile.fromSingleUri(ctx, uri) ?: throw IOException("DeleteFile: cannot resolve URI $uri") - if (!doc.delete()) { throw IOException("DeleteFile: delete() returned false for $uri") } } - @Throws(IOException::class) override fun getFileInfo(fileName: String): String { val context = appContext ?: throw IOException("app context not initialized") @@ -182,41 +220,32 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val dir = DocumentFile.fromTreeUri(context, Uri.parse(dirUri)) ?: throw IOException("could not resolve SAF root") - val file = dir.findFile(fileName) ?: throw IOException("file \"$fileName\" not found in SAF directory") - val name = file.name ?: throw IOException("file name missing for $fileName") val size = file.length() val modTime = file.lastModified() - return """{"name":${JSONObject.quote(name)},"size":$size,"modTime":$modTime}""" } - private fun jsonEscape(s: String): String { return JSONObject.quote(s) } - fun generateNewFilename(filename: String): String { val dotIndex = filename.lastIndexOf('.') val baseName = if (dotIndex != -1) filename.substring(0, dotIndex) else filename val extension = if (dotIndex != -1) filename.substring(dotIndex) else "" - val uuid = UUID.randomUUID() return "$baseName-$uuid$extension" } - fun listPartialFiles(suffix: String): Array { val context = appContext ?: return emptyArray() val rootUri = savedUri ?: return emptyArray() val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: return emptyArray() - return dir.listFiles() .filter { it.name?.endsWith(suffix) == true } .mapNotNull { it.name } .toTypedArray() } - @Throws(IOException::class) override fun listFilesJSON(suffix: String): String { val list = listPartialFiles(suffix) @@ -225,7 +254,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } return list.joinToString(prefix = "[\"", separator = "\",\"", postfix = "\"]") } - @Throws(IOException::class) override fun openFileReader(name: String): libtailscale.InputStream { val context = appContext ?: throw IOException("app context not initialized") @@ -233,37 +261,32 @@ object ShareFileHelper : libtailscale.ShareFileHelper { val dir = DocumentFile.fromTreeUri(context, Uri.parse(rootUri)) ?: throw IOException("could not open SAF root") - val suffix = name.substringAfterLast('.', ".$name") - val file = dir.listFiles().firstOrNull { val fname = it.name ?: return@firstOrNull false fname.endsWith(suffix, ignoreCase = false) } ?: throw IOException("no file ending with \"$suffix\" in SAF directory") - val inStream = context.contentResolver.openInputStream(file.uri) ?: throw IOException("openInputStream returned null for ${file.uri}") - return InputStreamAdapter(inStream) } + fun setUri(uri: String) { + savedUri = uri + } + private class SeekableOutputStream( private val fos: FileOutputStream, private val pfd: ParcelFileDescriptor ) : OutputStream() { - private var closed = false - override fun write(b: Int) = fos.write(b) - override fun write(b: ByteArray) = fos.write(b) - override fun write(b: ByteArray, off: Int, len: Int) { fos.write(b, off, len) } - override fun close() { if (!closed) { closed = true @@ -276,7 +299,6 @@ object ShareFileHelper : libtailscale.ShareFileHelper { } } } - override fun flush() = fos.flush() } -} +} \ No newline at end of file diff --git a/libtailscale/backend.go b/libtailscale/backend.go index bb1704d..b052693 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -58,8 +58,6 @@ type App struct { backend *ipnlocal.LocalBackend ready sync.WaitGroup backendMu sync.Mutex - - backendRestartCh chan struct{} } func start(dataDir, directFileRoot string, appCtx AppContext) Application { @@ -114,23 +112,6 @@ type backend struct { type settingsFunc func(*router.Config, *dns.OSConfig) error func (a *App) runBackend(ctx context.Context) error { - for { - err := a.runBackendOnce(ctx) - if err != nil { - log.Printf("runBackendOnce error: %v", err) - } - - // Wait for a restart trigger - <-a.backendRestartCh - } -} - -func (a *App) runBackendOnce(ctx context.Context) error { - select { - case <-a.backendRestartCh: - default: - } - paths.AppSharedDir.Store(a.dataDir) hostinfo.SetOSVersion(a.osVersion()) hostinfo.SetPackage(a.appCtx.GetInstallSource()) @@ -338,7 +319,6 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, lb, err := ipnlocal.NewLocalBackend(logf, logID.Public(), sys, 0) if ext, ok := ipnlocal.GetExt[*taildrop.Extension](lb); ok { ext.SetFileOps(newAndroidFileOps(a.shareFileHelper)) - ext.SetDirectFileRoot(a.directFileRoot) } if err != nil { @@ -368,14 +348,9 @@ func (a *App) newBackend(dataDir string, appCtx AppContext, store *stateStore, func (a *App) watchFileOpsChanges() { for { select { - case newPath := <-onFilePath: - log.Printf("Got new directFileRoot") - a.directFileRoot = newPath - a.backendRestartCh <- struct{}{} case helper := <-onShareFileHelper: - log.Printf("Got shareFIleHelper") + log.Printf("Got ShareFileHelper") a.shareFileHelper = helper - a.backendRestartCh <- struct{}{} } } } diff --git a/libtailscale/callbacks.go b/libtailscale/callbacks.go index 3e1a88f..9daec5c 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/interfaces.go b/libtailscale/interfaces.go index 67a108c..ca13070 100644 --- a/libtailscale/interfaces.go +++ b/libtailscale/interfaces.go @@ -243,7 +243,3 @@ func SetShareFileHelper(fileHelper ShareFileHelper) { onShareFileHelper <- fileHelper } } - -func SetDirectFileRoot(filePath string) { - onFilePath <- filePath -} diff --git a/libtailscale/tailscale.go b/libtailscale/tailscale.go index 3a785fa..ecbe0df 100644 --- a/libtailscale/tailscale.go +++ b/libtailscale/tailscale.go @@ -32,10 +32,9 @@ const ( func newApp(dataDir, directFileRoot string, appCtx AppContext) Application { a := &App{ - directFileRoot: directFileRoot, - dataDir: dataDir, - appCtx: appCtx, - backendRestartCh: make(chan struct{}, 1), + directFileRoot: directFileRoot, + dataDir: dataDir, + appCtx: appCtx, } a.ready.Add(2)