Skip to content

Commit a14d4c7

Browse files
authored
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 <[email protected]>
1 parent 88a5d3c commit a14d4c7

File tree

3 files changed

+63
-14
lines changed

3 files changed

+63
-14
lines changed

android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.outlined.Close
3232
import androidx.compose.material.icons.outlined.Lock
3333
import androidx.compose.material.icons.outlined.Search
3434
import androidx.compose.material.icons.outlined.Settings
35+
import androidx.compose.material3.AlertDialog
3536
import androidx.compose.material3.Button
3637
import androidx.compose.material3.DropdownMenu
3738
import androidx.compose.material3.DropdownMenuItem
@@ -61,12 +62,14 @@ import androidx.compose.ui.focus.onFocusChanged
6162
import androidx.compose.ui.graphics.Color
6263
import androidx.compose.ui.platform.LocalClipboardManager
6364
import androidx.compose.ui.platform.LocalFocusManager
65+
import androidx.compose.ui.platform.LocalUriHandler
6466
import androidx.compose.ui.res.painterResource
6567
import androidx.compose.ui.res.stringResource
6668
import androidx.compose.ui.text.SpanStyle
6769
import androidx.compose.ui.text.buildAnnotatedString
6870
import androidx.compose.ui.text.font.FontWeight
6971
import androidx.compose.ui.text.style.TextAlign
72+
import androidx.compose.ui.text.style.TextDecoration
7073
import androidx.compose.ui.text.style.TextOverflow
7174
import androidx.compose.ui.tooling.preview.Preview
7275
import androidx.compose.ui.unit.dp
@@ -78,11 +81,13 @@ import com.tailscale.ipn.App
7881
import com.tailscale.ipn.R
7982
import com.tailscale.ipn.mdm.MDMSettings
8083
import com.tailscale.ipn.mdm.ShowHide
84+
import com.tailscale.ipn.ui.Links
8185
import com.tailscale.ipn.ui.model.Ipn
8286
import com.tailscale.ipn.ui.model.IpnLocal
8387
import com.tailscale.ipn.ui.model.Netmap
8488
import com.tailscale.ipn.ui.model.Permissions
8589
import com.tailscale.ipn.ui.model.Tailcfg
90+
import com.tailscale.ipn.ui.theme.AppTheme
8691
import com.tailscale.ipn.ui.theme.customErrorContainer
8792
import com.tailscale.ipn.ui.theme.disabled
8893
import com.tailscale.ipn.ui.theme.errorButton
@@ -97,7 +102,6 @@ import com.tailscale.ipn.ui.theme.short
97102
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
98103
import com.tailscale.ipn.ui.theme.warningButton
99104
import com.tailscale.ipn.ui.theme.warningListItem
100-
import com.tailscale.ipn.ui.util.AndroidTVUtil
101105
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
102106
import com.tailscale.ipn.ui.util.AutoResizingText
103107
import com.tailscale.ipn.ui.util.Lists
@@ -124,7 +128,7 @@ data class MainViewNavigation(
124128
fun MainView(
125129
loginAtUrl: (String) -> Unit,
126130
navigation: MainViewNavigation,
127-
viewModel: MainViewModel
131+
viewModel: MainViewModel,
128132
) {
129133
val currentPingDevice by viewModel.pingViewModel.peer.collectAsState()
130134
val healthIcon by viewModel.healthIcon.collectAsState()
@@ -147,6 +151,8 @@ fun MainView(
147151
val showExitNodePicker by MDMSettings.exitNodesPicker.flow.collectAsState()
148152
val disableToggle by MDMSettings.forceEnabled.flow.collectAsState()
149153
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
154+
val showDirectoryPickerInterstitial by
155+
viewModel.showDirectoryPickerInterstitial.collectAsState()
150156

151157
// Hide the header only on Android TV when the user needs to login
152158
val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
@@ -214,8 +220,8 @@ fun MainView(
214220
viewModel.maybeRequestVpnPermission()
215221
LaunchVpnPermissionIfNeeded(viewModel)
216222
LaunchedEffect(state) {
217-
if (state == Ipn.State.Running && !AndroidTVUtil.isAndroidTV()) {
218-
viewModel.showDirectoryPickerLauncher()
223+
if (state == Ipn.State.Running && !isAndroidTV()) {
224+
viewModel.checkIfTaildropDirectorySelected()
219225
}
220226
}
221227

@@ -248,13 +254,29 @@ fun MainView(
248254
{ viewModel.login() },
249255
loginAtUrl,
250256
netmap?.SelfNode,
251-
{
252-
viewModel.showVPNPermissionLauncherIfUnauthorized()
253-
})
257+
{ viewModel.showVPNPermissionLauncherIfUnauthorized() })
254258
}
255259
}
256-
}
257260

261+
showDirectoryPickerInterstitial.let { show ->
262+
if (show) {
263+
AppTheme {
264+
AlertDialog(
265+
onDismissRequest = { viewModel.showDirectoryPickerLauncher() },
266+
title = {
267+
Text(text = stringResource(id = R.string.taildrop_directory_picker_title))
268+
},
269+
text = { TaildropDirectoryPickerPrompt() },
270+
confirmButton = {
271+
PrimaryActionButton(onClick = { viewModel.showDirectoryPickerLauncher() }) {
272+
Text(
273+
text = stringResource(id = R.string.taildrop_directory_picker_button))
274+
}
275+
})
276+
}
277+
}
278+
}
279+
}
258280
currentPingDevice?.let { _ ->
259281
ModalBottomSheet(onDismissRequest = { viewModel.onPingDismissal() }) {
260282
PingView(model = viewModel.pingViewModel)
@@ -264,6 +286,20 @@ fun MainView(
264286
}
265287
}
266288

289+
@Composable
290+
fun TaildropDirectoryPickerPrompt() {
291+
val uriHandler = LocalUriHandler.current
292+
293+
Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) {
294+
Text(text = stringResource(id = R.string.taildrop_directory_picker_body))
295+
Text(
296+
text = stringResource(id = R.string.taildrop_directory_picker_info),
297+
modifier = Modifier.clickable { uriHandler.openUri(Links.TAILDROP_KB_URL) },
298+
color = MaterialTheme.colorScheme.primary,
299+
textDecoration = TextDecoration.Underline)
300+
}
301+
}
302+
267303
@Composable
268304
fun LaunchVpnPermissionIfNeeded(viewModel: MainViewModel) {
269305
val lifecycleOwner = LocalLifecycleOwner.current
@@ -441,7 +477,7 @@ fun ConnectView(
441477
loginAction: () -> Unit,
442478
loginAtUrlAction: (String) -> Unit,
443479
selfNode: Tailcfg.Node?,
444-
showVPNPermissionLauncher: () -> Unit
480+
showVPNPermissionLauncher: () -> Unit,
445481
) {
446482
LaunchedEffect(isPrepared) {
447483
if (!isPrepared && shouldStartAutomatically) {
@@ -553,7 +589,7 @@ fun PeerList(
553589
viewModel: MainViewModel,
554590
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
555591
onSearchBarClick: () -> Unit,
556-
onSearch: (String) -> Unit
592+
onSearch: (String) -> Unit,
557593
) {
558594
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
559595
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
@@ -774,7 +810,7 @@ fun PromptPermissionsIfNecessary() {
774810
@Composable
775811
fun Search(
776812
onSearchBarClick: () -> Unit, // Callback for navigating to SearchView
777-
backgroundColor: Color = MaterialTheme.colorScheme.background // Default background color
813+
backgroundColor: Color = MaterialTheme.colorScheme.background, // Default background color
778814
) {
779815
// Prevent multiple taps
780816
var isNavigating by remember { mutableStateOf(false) }

android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
6868

6969
// Select Taildrop directory
7070
private var directoryPickerLauncher: ActivityResultLauncher<Uri?>? = null
71+
private val _showDirectoryPickerInterstitial = MutableStateFlow(false)
72+
val showDirectoryPickerInterstitial: StateFlow<Boolean> = _showDirectoryPickerInterstitial
7173

7274
// The list of peers
7375
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
@@ -211,11 +213,16 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
211213
}
212214

213215
fun showDirectoryPickerLauncher() {
216+
_showDirectoryPickerInterstitial.set(false)
217+
directoryPickerLauncher?.launch(null)
218+
}
219+
220+
fun checkIfTaildropDirectorySelected() {
214221
val app = App.get()
215222
val storedUri = app.getStoredDirectoryUri()
216223
if (storedUri == null) {
217224
// No stored URI, so launch the directory picker.
218-
directoryPickerLauncher?.launch(null)
225+
_showDirectoryPickerInterstitial.set(true)
219226
return
220227
}
221228

@@ -224,7 +231,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
224231
TSLog.d(
225232
"MainViewModel",
226233
"Stored directory URI is invalid or inaccessible; launching directory picker.")
227-
directoryPickerLauncher?.launch(null)
234+
_showDirectoryPickerInterstitial.set(true)
228235
} else {
229236
TSLog.d("MainViewModel", "Using stored directory URI: $storedUri")
230237
}
@@ -237,7 +244,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
237244
}
238245

239246
viewModelScope.launch {
240-
showDirectoryPickerLauncher()
247+
checkIfTaildropDirectorySelected()
241248
isToggleInProgress.value = true
242249
try {
243250
val currentState = Notifier.state.value

android/src/main/res/values/strings.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,10 @@
324324
<string name="hostname">Hostname</string>
325325
<string name="failed_to_save">Failed to save</string>
326326

327+
<!-- Strings for the taildrop directory picker interstitial -->
328+
<string name="taildrop_directory_picker_title">Taildrop Directory</string>
329+
<string name="taildrop_directory_picker_body">You have not selected a directory for incoming taildrop transfers. Please select or create a target directory.</string>
330+
<string name="taildrop_directory_picker_info">What is taildrop?</string>
331+
<string name="taildrop_directory_picker_button">Open Directory Picker</string>
332+
327333
</resources>

0 commit comments

Comments
 (0)