From 741c656de038c63e192b5b5835ddbdca4acf8278 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 28 May 2025 15:45:44 -0400 Subject: [PATCH] android: add explanatory dialog for taildrop directory selection (#657) fixes tailscale/corp#29067 Adds an interstitial explaining that the user needs to select/create a taildrop target directory on startup. Signed-off-by: Jonathan Nobels (cherry picked from commit a14d4c7184c312c2ae53e0be0cab3ffee312fe77) --- .../com/tailscale/ipn/ui/view/MainView.kt | 58 +++++++++++++++---- .../ipn/ui/viewModel/MainViewModel.kt | 13 ++++- android/src/main/res/values/strings.xml | 6 ++ 3 files changed, 63 insertions(+), 14 deletions(-) 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 96b0491..71f87f5 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,6 +32,7 @@ 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 @@ -61,12 +62,14 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString 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 @@ -78,11 +81,13 @@ import com.tailscale.ipn.App import com.tailscale.ipn.R import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.ShowHide +import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.model.Ipn 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 @@ -97,7 +102,6 @@ import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningListItem -import com.tailscale.ipn.ui.util.AndroidTVUtil import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.Lists @@ -124,7 +128,7 @@ data class MainViewNavigation( fun MainView( loginAtUrl: (String) -> Unit, navigation: MainViewNavigation, - viewModel: MainViewModel + viewModel: MainViewModel, ) { val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState() @@ -147,6 +151,8 @@ 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) @@ -214,8 +220,8 @@ fun MainView( viewModel.maybeRequestVpnPermission() LaunchVpnPermissionIfNeeded(viewModel) LaunchedEffect(state) { - if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) { - viewModel.showDirectoryPickerLauncher() + if (state == Ipn.State.Running && !isAndroidTV()) { + viewModel.checkIfTaildropDirectorySelected() } } @@ -248,13 +254,29 @@ fun MainView( { viewModel.login() }, loginAtUrl, netmap?.SelfNode, - { - viewModel.showVPNPermissionLauncherIfUnauthorized() - }) + { 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() }) { PingView(model = viewModel.pingViewModel) @@ -264,6 +286,20 @@ 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( + text = stringResource(id = R.string.taildrop_directory_picker_info), + modifier = Modifier.clickable { uriHandler.openUri(Links.TAILDROP_KB_URL) }, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline) + } +} + @Composable fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { val lifecycleOwner = LocalLifecycleOwner.current @@ -441,7 +477,7 @@ fun ConnectView( loginAction: () -> Unit, loginAtUrlAction: (String) -> Unit, selfNode: Tailcfg.Node?, - showVPNPermissionLauncher: () -> Unit + showVPNPermissionLauncher: () -> Unit, ) { LaunchedEffect(isPrepared) { if (!isPrepared && shouldStartAutomatically) { @@ -553,7 +589,7 @@ fun PeerList( viewModel: MainViewModel, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearchBarClick: () -> Unit, - onSearch: (String) -> Unit + onSearch: (String) -> Unit, ) { val peerList by viewModel.peers.collectAsState(initial = emptyList()) val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") @@ -774,7 +810,7 @@ fun PromptPermissionsIfNecessary() { @Composable fun Search( onSearchBarClick: () -> Unit, // Callback for navigating to SearchView - backgroundColor: Color = MaterialTheme.colorScheme.background // Default background color + backgroundColor: Color = MaterialTheme.colorScheme.background, // Default background color ) { // Prevent multiple taps var isNavigating by remember { mutableStateOf(false) } 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 7190764..332c77e 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 @@ -68,6 +68,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { // 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()) @@ -211,11 +213,16 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } fun showDirectoryPickerLauncher() { + _showDirectoryPickerInterstitial.set(false) + directoryPickerLauncher?.launch(null) + } + + fun checkIfTaildropDirectorySelected() { val app = App.get() val storedUri = app.getStoredDirectoryUri() if (storedUri == null) { // No stored URI, so launch the directory picker. - directoryPickerLauncher?.launch(null) + _showDirectoryPickerInterstitial.set(true) return } @@ -224,7 +231,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { TSLog.d( "MainViewModel", "Stored directory URI is invalid or inaccessible; launching directory picker.") - directoryPickerLauncher?.launch(null) + _showDirectoryPickerInterstitial.set(true) } else { TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") } @@ -237,7 +244,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { } viewModelScope.launch { - showDirectoryPickerLauncher() + checkIfTaildropDirectorySelected() isToggleInProgress.value = true try { val currentState = Notifier.state.value diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 5626e58..d376490 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -324,4 +324,10 @@ Hostname Failed to save + + Taildrop Directory + You have not selected a directory for incoming taildrop transfers. Please select or create a target directory. + What is taildrop? + Open Directory Picker +