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 <jonathan@tailscale.com>
(cherry picked from commit a14d4c7184)
release-branch/1.84
Jonathan Nobels 6 months ago committed by Jonathan Nobels
parent 719074f9c5
commit 741c656de0

@ -32,6 +32,7 @@ import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem 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.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -78,11 +81,13 @@ import com.tailscale.ipn.App
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.mdm.ShowHide 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.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg 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.customErrorContainer
import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.errorButton 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.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem 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.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
@ -124,7 +128,7 @@ data class MainViewNavigation(
fun MainView( fun MainView(
loginAtUrl: (String) -> Unit, loginAtUrl: (String) -> Unit,
navigation: MainViewNavigation, navigation: MainViewNavigation,
viewModel: MainViewModel viewModel: MainViewModel,
) { ) {
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState() val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
val healthIcon by viewModel.healthIcon.collectAsState() val healthIcon by viewModel.healthIcon.collectAsState()
@ -147,6 +151,8 @@ fun MainView(
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState() val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState() val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) 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 // Hide the header only on Android TV when the user needs to login
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin) val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
@ -214,8 +220,8 @@ fun MainView(
viewModel.maybeRequestVpnPermission() viewModel.maybeRequestVpnPermission()
LaunchVpnPermissionIfNeeded(viewModel) LaunchVpnPermissionIfNeeded(viewModel)
LaunchedEffect(state) { LaunchedEffect(state) {
if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) { if (state == Ipn.State.Running && !isAndroidTV()) {
viewModel.showDirectoryPickerLauncher() viewModel.checkIfTaildropDirectorySelected()
} }
} }
@ -248,13 +254,29 @@ fun MainView(
{ viewModel.login() }, { viewModel.login() },
loginAtUrl, loginAtUrl,
netmap?.SelfNode, 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 { _ -> currentPingDevice?.let { _ ->
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) { ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
PingView(model = viewModel.pingViewModel) 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 @Composable
fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) { fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@ -441,7 +477,7 @@ fun ConnectView(
loginAction: () -> Unit, loginAction: () -> Unit,
loginAtUrlAction: (String) -> Unit, loginAtUrlAction: (String) -> Unit,
selfNode: Tailcfg.Node?, selfNode: Tailcfg.Node?,
showVPNPermissionLauncher: () -> Unit showVPNPermissionLauncher: () -> Unit,
) { ) {
LaunchedEffect(isPrepared) { LaunchedEffect(isPrepared) {
if (!isPrepared && shouldStartAutomatically) { if (!isPrepared && shouldStartAutomatically) {
@ -553,7 +589,7 @@ fun PeerList(
viewModel: MainViewModel, viewModel: MainViewModel,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearchBarClick: () -> Unit, onSearchBarClick: () -> Unit,
onSearch: (String) -> Unit onSearch: (String) -> Unit,
) { ) {
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>()) val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
@ -774,7 +810,7 @@ fun PromptPermissionsIfNecessary() {
@Composable @Composable
fun Search( fun Search(
onSearchBarClick: () -> Unit, // Callback for navigating to SearchView 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 // Prevent multiple taps
var isNavigating by remember { mutableStateOf(false) } var isNavigating by remember { mutableStateOf(false) }

@ -68,6 +68,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// Select Taildrop directory // Select Taildrop directory
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
private val _showDirectoryPickerInterstitial = MutableStateFlow(false)
val showDirectoryPickerInterstitial: StateFlow<Boolean> = _showDirectoryPickerInterstitial
// The list of peers // The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList()) private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
@ -211,11 +213,16 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
fun showDirectoryPickerLauncher() { fun showDirectoryPickerLauncher() {
_showDirectoryPickerInterstitial.set(false)
directoryPickerLauncher?.launch(null)
}
fun checkIfTaildropDirectorySelected() {
val app = App.get() val app = App.get()
val storedUri = app.getStoredDirectoryUri() val storedUri = app.getStoredDirectoryUri()
if (storedUri == null) { if (storedUri == null) {
// No stored URI, so launch the directory picker. // No stored URI, so launch the directory picker.
directoryPickerLauncher?.launch(null) _showDirectoryPickerInterstitial.set(true)
return return
} }
@ -224,7 +231,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
TSLog.d( TSLog.d(
"MainViewModel", "MainViewModel",
"Stored directory URI is invalid or inaccessible; launching directory picker.") "Stored directory URI is invalid or inaccessible; launching directory picker.")
directoryPickerLauncher?.launch(null) _showDirectoryPickerInterstitial.set(true)
} else { } else {
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri") TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
} }
@ -237,7 +244,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
showDirectoryPickerLauncher() checkIfTaildropDirectorySelected()
isToggleInProgress.value = true isToggleInProgress.value = true
try { try {
val currentState = Notifier.state.value val currentState = Notifier.state.value

@ -324,4 +324,10 @@
<string name="hostname">Hostname</string> <string name="hostname">Hostname</string>
<string name="failed_to_save">Failed to save</string> <string name="failed_to_save">Failed to save</string>
<!-- Strings for the taildrop directory picker interstitial -->
<string name="taildrop_directory_picker_title">Taildrop Directory</string>
<string name="taildrop_directory_picker_body">You have not selected a directory for incoming taildrop transfers. Please select or create a target directory.</string>
<string name="taildrop_directory_picker_info">What is taildrop?</string>
<string name="taildrop_directory_picker_button">Open Directory Picker</string>
</resources> </resources>

Loading…
Cancel
Save