@ -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 . is AndroidTV( ) ) {
viewModel . showDirectoryPickerLauncher ( )
if ( state == Ipn . State . Running && !is AndroidTV( ) ) {
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 ( !is Prepared && 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 ) }