diff --git a/Makefile b/Makefile index 342e0bc..ea93133 100644 --- a/Makefile +++ b/Makefile @@ -13,19 +13,6 @@ DOCKER_IMAGE := tailscale-android-build-amd64-041425-1 export TS_USE_TOOLCHAIN=1 -# Auto-select an NDK from ANDROID_HOME (choose highest version available) -NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1) - -HOST_OS := $(shell uname | tr A-Z a-z) -ifeq ($(HOST_OS),linux) - STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy -else ifeq ($(HOST_OS),darwin) - STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy -endif - -$(info Using NDK_ROOT: $(NDK_ROOT)) -$(info Using STRIP_TOOL: $(STRIP_TOOL)) - DEBUG_APK := tailscale-debug.apk RELEASE_AAB := tailscale-release.aab RELEASE_TV_AAB := tailscale-tv-release.aab @@ -64,6 +51,21 @@ ifeq ($(ANDROID_SDK_ROOT),) endif export ANDROID_HOME ?= $(ANDROID_SDK_ROOT) +# Auto-select an NDK from ANDROID_HOME (choose highest version available) +NDK_ROOT ?= $(shell ls -1d $(ANDROID_HOME)/ndk/* 2>/dev/null | sort -V | tail -n 1) + +HOST_OS := $(shell uname | tr A-Z a-z) +ifeq ($(HOST_OS),linux) + STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy +else ifeq ($(HOST_OS),darwin) + STRIP_TOOL := $(NDK_ROOT)/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-objcopy +endif + +$(info Using ANDROID_HOME: $(ANDROID_HOME)) +$(info Using NDK_ROOT: $(NDK_ROOT)) +$(info Using STRIP_TOOL: $(STRIP_TOOL)) + + # Attempt to find Android Studio for Linux configuration, which does not have a # predetermined location. ANDROID_STUDIO_ROOT ?= $(shell find ~/android-studio /usr/local/android-studio /opt/android-studio /Applications/Android\ Studio.app $(PROGRAMFILES)/Android/Android\ Studio -type d -maxdepth 1 2>/dev/null | head -n 1) @@ -312,7 +314,7 @@ checkandroidsdk: ## Check that Android SDK is installed test: gradle-dependencies ## Run the Android tests (cd android && ./gradlew test) -.PHONY: emulator +.PHONY: emulator emulator: ## Start an android emulator instance @echo "Checking installed SDK packages..." @if ! $(ANDROID_HOME)/cmdline-tools/latest/bin/sdkmanager --list_installed | grep -q "$(AVD_IMAGE)"; then \ @@ -327,7 +329,7 @@ emulator: ## Start an android emulator instance @echo "Starting emulator..." @$(ANDROID_HOME)/emulator/emulator -avd "$(AVD)" -logcat-output /dev/stdout -netdelay none -netspeed full -.PHONY: install +.PHONY: install install: $(DEBUG_APK) ## Install the debug APK on a connected device adb install -r $< @@ -335,7 +337,7 @@ install: $(DEBUG_APK) ## Install the debug APK on a connected device run: install ## Run the debug APK on a connected device adb shell am start -n com.tailscale.ipn/com.tailscale.ipn.MainActivity -.PHONY: docker-build-image +.PHONY: docker-build-image docker-build-image: ## Builds the docker image for the android build environment if it does not exist @echo "Checking if docker image $(DOCKER_IMAGE) already exists..." @if ! docker images $(DOCKER_IMAGE) -q | grep -q . ; then \ diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 67a0a62..5794ac5 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) { @@ -250,11 +252,12 @@ 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() } @@ -400,7 +403,7 @@ open class UninitializedApp : Application() { private lateinit var appInstance: UninitializedApp lateinit var notificationManager: NotificationManagerCompat - lateinit var vpnViewModel: VpnViewModel + lateinit var appViewModel: AppViewModel @JvmStatic fun get(): UninitializedApp { @@ -587,8 +590,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 dbcbc3f..efa5ddc 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -34,13 +34,20 @@ 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 import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.NavType @@ -75,39 +82,43 @@ import com.tailscale.ipn.ui.view.MullvadInfoView import com.tailscale.ipn.ui.view.NotificationsView import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView +import com.tailscale.ipn.ui.view.PrimaryActionButton import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.SearchView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView import com.tailscale.ipn.ui.view.SubnetRoutingView import com.tailscale.ipn.ui.view.TaildropDirView +import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView +import com.tailscale.ipn.ui.viewModel.AppViewModel +import com.tailscale.ipn.ui.viewModel.AppViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModelFactory import com.tailscale.ipn.ui.viewModel.PermissionsViewModel import com.tailscale.ipn.ui.viewModel.PingViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav -import com.tailscale.ipn.ui.viewModel.VpnViewModel +import com.tailscale.ipn.util.ShareFileHelper import com.tailscale.ipn.util.TSLog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import libtailscale.Libtailscale class MainActivity : ComponentActivity() { private lateinit var navController: NavHostController private lateinit var vpnPermissionLauncher: ActivityResultLauncher - private val viewModel: MainViewModel by lazy { - val app = App.get() - vpnViewModel = app.getAppScopedViewModel() - ViewModelProvider(this, MainViewModelFactory(vpnViewModel)).get(MainViewModel::class.java) + private val appViewModel: AppViewModel by viewModels { + AppViewModelFactory( + application = this.application, taildropPrompt = ShareFileHelper.taildropPrompt) } - private lateinit var vpnViewModel: VpnViewModel + + private val viewModel: MainViewModel by viewModels { MainViewModelFactory(appViewModel) } + val permissionsViewModel: PermissionsViewModel by viewModels() companion object { @@ -132,7 +143,6 @@ class MainActivity : ComponentActivity() { // grab app to make sure it initializes App.get() - vpnViewModel = ViewModelProvider(App.get()).get(VpnViewModel::class.java) val rm = getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager MDMSettings.update(App.get(), rm) @@ -154,7 +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 @@ -318,7 +362,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) } 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..5912b1a 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 @@ -32,7 +32,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 @@ -71,13 +70,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.tailscale.ipn.App import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.ShowHide @@ -87,7 +84,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,9 +105,9 @@ 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 // Navigation actions for the MainView @@ -129,9 +125,17 @@ fun MainView( loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, viewModel: MainViewModel, + appViewModel: AppViewModel ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState() + val showDirectoryPicker = appViewModel.showDirectoryPickerInterstitial.collectAsState(null) + + LaunchedEffect(showDirectoryPicker.value) { + if (showDirectoryPicker.value != null) { + appViewModel.directoryPickerLauncher?.launch(null) + } + } LoadingIndicator.Wrap { Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> @@ -151,8 +155,6 @@ fun MainView( val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) - val showDirectoryPickerInterstitial by - viewModel.showDirectoryPickerInterstitial.collectAsState() // Hide the header only on Android TV when the user needs to login val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) @@ -222,7 +224,7 @@ fun MainView( if (!viewModel.skipPromptsForAuthKeyLogin()) { LaunchedEffect(state) { if (state == Ipn.State.Running && !isAndroidTV()) { - viewModel.checkIfTaildropDirectorySelected() + appViewModel.checkIfTaildropDirectorySelected() } } } @@ -259,25 +261,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() }) { @@ -865,12 +848,12 @@ fun Search( } } } - +/* @Preview @Composable fun MainViewPreview() { - val vpnViewModel = VpnViewModel(App.get()) - val vm = MainViewModel(vpnViewModel) + val appViewModel = AppViewModel(App.get()) + val vm = MainViewModel(appViewModel) MainView( {}, @@ -880,5 +863,7 @@ 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..2dc187f 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 @@ -38,15 +38,15 @@ import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AppVersion import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.AppViewModel import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsViewModel -import com.tailscale.ipn.ui.viewModel.VpnViewModel @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..a52bf8a --- /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 + } +} \ 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..5ecdf08 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 @@ -39,18 +39,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)) @@ -98,9 +98,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 @@ -215,41 +215,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() || 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 +228,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 { @@ -297,10 +265,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 a6ee734..0000000 --- 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 a15636b..3733689 100644 --- a/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/util/ShareFileHelper.kt @@ -8,8 +8,15 @@ 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 @@ -22,18 +29,64 @@ 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 @@ -74,6 +127,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { @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,6 +138,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { @Throws(IOException::class) override fun getFileURI(fileName: String): String { + runBlocking { waitUntilTaildropDirReady() } currentUri[fileName]?.let { return it } @@ -108,7 +163,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 +172,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 +180,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,7 +197,7 @@ object ShareFileHelper : libtailscale.ShareFileHelper { inp.copyTo(out) } } - + ctx.contentResolver.delete(srcUri, null, null) cleanupPartials(dir, targetName) return dest.uri.toString() @@ -249,6 +304,10 @@ object ShareFileHelper : libtailscale.ShareFileHelper { return InputStreamAdapter(inStream) } + fun setUri(uri: String) { + savedUri = uri + } + private class SeekableOutputStream( private val fos: FileOutputStream, private val pfd: ParcelFileDescriptor diff --git a/libtailscale/backend.go b/libtailscale/backend.go index bb1704d..0b046b4 100644 --- a/libtailscale/backend.go +++ b/libtailscale/backend.go @@ -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()) @@ -338,7 +321,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 +350,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 -}