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.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<PeerSet>())
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) }

@ -68,6 +68,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// Select Taildrop directory
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
private val _showDirectoryPickerInterstitial = MutableStateFlow(false)
val showDirectoryPickerInterstitial: StateFlow<Boolean> = _showDirectoryPickerInterstitial
// The list of peers
private val _peers = MutableStateFlow<List<PeerSet>>(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

@ -324,4 +324,10 @@
<string name="hostname">Hostname</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>

Loading…
Cancel
Save