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