android: add main screen device details and basic nav (#191)
updates tailscale/corp#18202 updates ENG-2835 updates ENG-2859 Adds the peer details view and some supporting utilities. Eliminates all of the singletons. None of this is styled correctly, but the layouts match iOS. Signed-off-by: Jonathan Nobels jonathan@tailscale.com --------- Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>pull/196/head
parent
87a8003d39
commit
3926cf4b56
@ -0,0 +1,64 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.tailscale.ipn.ui.service.IpnManager
|
||||
import com.tailscale.ipn.ui.theme.AppTheme
|
||||
import com.tailscale.ipn.ui.view.ExitNodePicker
|
||||
import com.tailscale.ipn.ui.view.MainView
|
||||
import com.tailscale.ipn.ui.view.MainViewNavigation
|
||||
import com.tailscale.ipn.ui.view.PeerDetails
|
||||
import com.tailscale.ipn.ui.view.Settings
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
|
||||
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
|
||||
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val manager = IpnManager()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController = navController, startDestination = "main") {
|
||||
val mainViewNav = MainViewNavigation(
|
||||
onNavigateToSettings = { navController.navigate("settings") },
|
||||
onNavigateToPeerDetails = {
|
||||
navController.navigate("peerDetails/${it.StableID}")
|
||||
},
|
||||
onNavigateToExitNodes = { navController.navigate("exitNodes") }
|
||||
)
|
||||
|
||||
composable("main") {
|
||||
MainView(viewModel = MainViewModel(manager.model, manager.actions), navigation = mainViewNav)
|
||||
}
|
||||
composable("settings") {
|
||||
Settings(SettingsViewModel(manager.model))
|
||||
}
|
||||
composable("exitNodes") {
|
||||
ExitNodePicker(ExitNodePickerViewModel(manager.model))
|
||||
}
|
||||
composable("peerDetails/{nodeId}", arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
|
||||
PeerDetails(PeerDetailsViewModel(manager.model, nodeId = it.arguments?.getString("nodeId")
|
||||
?: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,14 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.mdm
|
||||
|
||||
enum class NetworkDevices(val value: String) {
|
||||
currentUser("current-user"),
|
||||
otherUsers("other-users"),
|
||||
taggedDevices("tagged-devices"),
|
||||
}
|
||||
|
||||
class MDMSettings {
|
||||
val hiddenNetworkDevices: List<NetworkDevices> = emptyList()
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val md_theme_light_primary = Color(0xFF006A61)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFF73F8E8)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF00201D)
|
||||
val md_theme_light_secondary = Color(0xFF4A635F)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFCCE8E3)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF05201C)
|
||||
val md_theme_light_tertiary = Color(0xFF46617A)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFCDE5FF)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF001D32)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFAFDFB)
|
||||
val md_theme_light_onBackground = Color(0xFF191C1C)
|
||||
val md_theme_light_surface = Color(0xFFFAFDFB)
|
||||
val md_theme_light_onSurface = Color(0xFF191C1C)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFDAE5E2)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF3F4947)
|
||||
val md_theme_light_outline = Color(0xFF6F7977)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFEFF1EF)
|
||||
val md_theme_light_inverseSurface = Color(0xFF2D3130)
|
||||
val md_theme_light_inversePrimary = Color(0xFF52DBCB)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
val md_theme_light_surfaceTint = Color(0xFF006A61)
|
||||
val md_theme_light_outlineVariant = Color(0xFFBEC9C6)
|
||||
val md_theme_light_scrim = Color(0xFF000000)
|
||||
|
||||
val md_theme_dark_primary = Color(0xFF52DBCB)
|
||||
val md_theme_dark_onPrimary = Color(0xFF003732)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF005049)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFF73F8E8)
|
||||
val md_theme_dark_secondary = Color(0xFFB1CCC7)
|
||||
val md_theme_dark_onSecondary = Color(0xFF1C3531)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF324B48)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFCCE8E3)
|
||||
val md_theme_dark_tertiary = Color(0xFFAEC9E6)
|
||||
val md_theme_dark_onTertiary = Color(0xFF163349)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF2E4961)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFCDE5FF)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF191C1C)
|
||||
val md_theme_dark_onBackground = Color(0xFFE0E3E1)
|
||||
val md_theme_dark_surface = Color(0xFF191C1C)
|
||||
val md_theme_dark_onSurface = Color(0xFFE0E3E1)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF3F4947)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFBEC9C6)
|
||||
val md_theme_dark_outline = Color(0xFF899390)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF191C1C)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE0E3E1)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF006A61)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
val md_theme_dark_surfaceTint = Color(0xFF52DBCB)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF3F4947)
|
||||
val md_theme_dark_scrim = Color(0xFF000000)
|
||||
|
||||
|
||||
val seed = Color(0xFF006B62)
|
@ -0,0 +1,94 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim,
|
||||
)
|
||||
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
useDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable() () -> Unit
|
||||
) {
|
||||
val colors = if (!useDarkTheme) {
|
||||
LightColors
|
||||
} else {
|
||||
DarkColors
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
content = content
|
||||
)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
class DisplayAddress(val ip: String) {
|
||||
enum class addrType {
|
||||
V4, V6, MagicDNS
|
||||
}
|
||||
|
||||
val type: addrType = when {
|
||||
ip.contains(":") -> addrType.V6
|
||||
ip.contains(".") -> addrType.V4
|
||||
else -> addrType.MagicDNS
|
||||
}
|
||||
|
||||
val typeString: String = when (type) {
|
||||
addrType.V4 -> "IPv4"
|
||||
addrType.V6 -> "IPv6"
|
||||
addrType.MagicDNS -> "MagicDNS"
|
||||
}
|
||||
|
||||
val address: String = when (type) {
|
||||
addrType.MagicDNS -> ip
|
||||
else -> ip.split("/").first()
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
import com.tailscale.ipn.ui.model.Netmap
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.model.UserID
|
||||
import com.tailscale.ipn.ui.service.IpnModel
|
||||
|
||||
|
||||
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
|
||||
|
||||
class PeerCategorizer(val model: IpnModel) {
|
||||
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
|
||||
val netmap: Netmap.NetworkMap = model.netmap.value ?: return emptyList()
|
||||
val peers: List<Tailcfg.Node> = netmap.Peers ?: return emptyList()
|
||||
val selfNode = netmap.SelfNode
|
||||
|
||||
val grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
|
||||
for (peer in (peers + selfNode)) {
|
||||
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
|
||||
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
|
||||
|
||||
val userId = peer.User
|
||||
if (searchTerm.isNotEmpty() && !peer.ComputedName.contains(searchTerm, ignoreCase = true)) {
|
||||
continue
|
||||
}
|
||||
if (!grouped.containsKey(userId)) {
|
||||
grouped[userId] = mutableListOf()
|
||||
}
|
||||
grouped[userId]?.add(peer)
|
||||
}
|
||||
val selfPeers = grouped[selfNode.User] ?: emptyList()
|
||||
grouped.remove(selfNode.User)
|
||||
|
||||
var sorted = grouped.map { (userId, peers) ->
|
||||
val profile = netmap.userProfile(userId)
|
||||
PeerSet(profile, peers)
|
||||
}.sortedBy {
|
||||
it.user?.DisplayName ?: "Unknown User"
|
||||
}
|
||||
|
||||
val me = netmap.currentUserProfile()
|
||||
return listOf(PeerSet(me, selfPeers)) + sorted
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package com.tailscale.ipn.ui.util
|
||||
|
||||
class TimeUtil {
|
||||
fun keyExpiryFromGoTime(goTime: String?): String {
|
||||
// (jonathan) TODO: Turn these time strings into 'in 4 months', 'in 2 days', 'in 1 year', etc
|
||||
return goTime ?: "Never"
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun ExitNodePicker(viewModel: ExitNodePickerViewModel) {
|
||||
Column {
|
||||
Text(text = "Future Home of Picking Exit Nodes")
|
||||
}
|
||||
}
|
@ -0,0 +1,254 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.outlined.ArrowDropDown
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.ui.model.Ipn
|
||||
import com.tailscale.ipn.ui.model.IpnLocal
|
||||
import com.tailscale.ipn.ui.model.Tailcfg
|
||||
import com.tailscale.ipn.ui.util.PeerSet
|
||||
import com.tailscale.ipn.ui.viewModel.MainViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
|
||||
// Navigation actions for the MainView
|
||||
data class MainViewNavigation(
|
||||
val onNavigateToSettings: () -> Unit,
|
||||
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
|
||||
val onNavigateToExitNodes: () -> Unit)
|
||||
|
||||
|
||||
@Composable
|
||||
fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
|
||||
val user = viewModel.loggedInUser.collectAsState(initial = null)
|
||||
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
|
||||
|
||||
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
|
||||
StateDisplay(viewModel.stateStr, viewModel.userName)
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
SettingsButton(user.value, navigation.onNavigateToSettings)
|
||||
}
|
||||
}
|
||||
|
||||
// (jonathan) TODO: Show the selected exit node name here.
|
||||
if (state.value == Ipn.State.Running) {
|
||||
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, "None")
|
||||
}
|
||||
|
||||
when (state.value) {
|
||||
Ipn.State.Running -> PeerList(
|
||||
searchTerm = viewModel.searchTerm,
|
||||
peers = viewModel.peers,
|
||||
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
|
||||
onSearch = { viewModel.searchPeers(it) })
|
||||
|
||||
Ipn.State.Starting -> StartingView()
|
||||
else ->
|
||||
ConnectView(
|
||||
user.value,
|
||||
{ viewModel.toggleVpn() },
|
||||
{ viewModel.login() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = "None") {
|
||||
Box(modifier = Modifier
|
||||
.clickable { navAction() }
|
||||
.padding(horizontal = 8.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(6.dp)) {
|
||||
Text(text = "Exit Node", style = MaterialTheme.typography.titleMedium)
|
||||
Row {
|
||||
Text(text = exitNode, style = MaterialTheme.typography.bodyMedium)
|
||||
Icon(
|
||||
Icons.Outlined.ArrowDropDown,
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StateDisplay(state: StateFlow<String>, tailnet: String) {
|
||||
val stateStr = state.collectAsState(initial = "--")
|
||||
|
||||
Column(modifier = Modifier.padding(6.dp)) {
|
||||
Text(text = "${tailnet}", style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = "${stateStr.value}", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
|
||||
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
|
||||
IconButton(
|
||||
modifier = Modifier.size(24.dp),
|
||||
onClick = { action() }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Settings,
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StartingView() {
|
||||
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) { Text(text = "Starting...", style = MaterialTheme.typography.titleMedium) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = "Not Connected", style = MaterialTheme.typography.titleMedium)
|
||||
if (user != null) {
|
||||
val tailnetName = user.NetworkProfile?.DomainName ?: ""
|
||||
Text(
|
||||
"Connect to your ${tailnetName} tailnet",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Button(onClick = connectAction) { Text(text = "Connect") }
|
||||
} else {
|
||||
Button(onClick = loginAction) { Text(text = "Log In") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PeerList(searchTerm: StateFlow<String>, peers: StateFlow<List<PeerSet>>, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onSearch: (String) -> Unit) {
|
||||
val peerList = peers.collectAsState(initial = emptyList<PeerSet>())
|
||||
var searching = false
|
||||
val searchTermStr by searchTerm.collectAsState(initial = "")
|
||||
|
||||
SearchBar(
|
||||
query = searchTermStr,
|
||||
onQueryChange = onSearch,
|
||||
onSearch = onSearch,
|
||||
active = true,
|
||||
onActiveChange = { searching = it },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
leadingIcon = { Icon(Icons.Outlined.Search, null) },
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
colors = SearchBarDefaults.colors(),
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
peerList.value.forEach { peerSet ->
|
||||
ListItem(headlineContent = {
|
||||
Text(text = peerSet.user?.DisplayName
|
||||
?: "Unknown User", style = MaterialTheme.typography.titleLarge)
|
||||
})
|
||||
peerSet.peers.forEach { peer ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
onNavigateToPeerDetails(peer)
|
||||
},
|
||||
headlineContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val color: Color = if (peer.Online ?: false) {
|
||||
Color.Green
|
||||
} else {
|
||||
Color.Gray
|
||||
}
|
||||
Box(modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun PeerDetails(viewModel: PeerDetailsViewModel) {
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = viewModel.nodeName, style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(text = viewModel.connectedStr, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
|
||||
Text(text = "TAILSCALE ADDRESSES", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
Column(modifier = Modifier
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
.background(color = MaterialTheme.colorScheme.secondaryContainer)
|
||||
.fillMaxWidth()) {
|
||||
viewModel.addresses.forEach {
|
||||
AddressRow(address = it.address, type = it.typeString)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
|
||||
Column(modifier = Modifier
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
.background(color = MaterialTheme.colorScheme.secondaryContainer)
|
||||
.fillMaxWidth()) {
|
||||
viewModel.info.forEach {
|
||||
ValueRow(title = it.title, value = it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddressRow(address: String, type: String) {
|
||||
val localClipboardManager = LocalClipboardManager.current
|
||||
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) {
|
||||
Column {
|
||||
Text(text = address, style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = type, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
Icon(Icons.Outlined.Share, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ValueRow(title: String, value: String) {
|
||||
Row(modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.fillMaxWidth()) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
Text(text = value, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.view
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.tailscale.ipn.ui.viewModel.SettingsViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun Settings(viewModel: SettingsViewModel) {
|
||||
Column {
|
||||
Text(text = "Future Home of Settings")
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.tailscale.ipn.ui.service.IpnModel
|
||||
|
||||
class ExitNodePickerViewModel(val model: IpnModel) : ViewModel() {
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.tailscale.ipn.ui.model.Ipn.State
|
||||
import com.tailscale.ipn.ui.service.IpnActions
|
||||
import com.tailscale.ipn.ui.service.IpnModel
|
||||
import com.tailscale.ipn.ui.util.PeerCategorizer
|
||||
import com.tailscale.ipn.ui.util.PeerSet
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() {
|
||||
|
||||
private val _stateStr = MutableStateFlow<String>("")
|
||||
private val _tailnetName = MutableStateFlow<String>("")
|
||||
private val _vpnToggleState = MutableStateFlow<Boolean>(false)
|
||||
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList<PeerSet>())
|
||||
|
||||
// The user readable state of the system
|
||||
val stateStr = _stateStr.asStateFlow()
|
||||
|
||||
// The current state of the IPN for determining view visibility
|
||||
val ipnState = model.state
|
||||
|
||||
// The name of the tailnet
|
||||
val tailnetName = _tailnetName.asStateFlow()
|
||||
|
||||
// The expected state of the VPN toggle
|
||||
val vpnToggleState = _vpnToggleState.asStateFlow()
|
||||
|
||||
// The list of peers
|
||||
val peers = _peers.asStateFlow()
|
||||
|
||||
// The logged in user
|
||||
val loggedInUser = model.loggedInUser
|
||||
|
||||
// The active search term for filtering peers
|
||||
val searchTerm = MutableStateFlow<String>("")
|
||||
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
model.state.collect { state ->
|
||||
_stateStr.value = state.userString()
|
||||
_vpnToggleState.value = (state == State.Running || state == State.Starting)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
model.netmap.collect { netmap ->
|
||||
_tailnetName.value = netmap?.Domain ?: ""
|
||||
_peers.value = PeerCategorizer(model).groupedAndFilteredPeers(searchTerm.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchPeers(searchTerm: String) {
|
||||
this.searchTerm.value = searchTerm
|
||||
viewModelScope.launch {
|
||||
_peers.value = PeerCategorizer(model).groupedAndFilteredPeers(searchTerm)
|
||||
}
|
||||
}
|
||||
|
||||
val userName: String
|
||||
get() {
|
||||
return loggedInUser.value?.Name ?: ""
|
||||
}
|
||||
|
||||
fun toggleVpn() {
|
||||
when (model.state.value) {
|
||||
State.Running -> actions.stopVPN()
|
||||
else -> actions.startVPN()
|
||||
}
|
||||
}
|
||||
|
||||
fun login() {
|
||||
actions.login()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun State?.userString(): String {
|
||||
return when (this) {
|
||||
State.NoState -> "Waiting..."
|
||||
State.InUseOtherUser -> "--"
|
||||
State.NeedsLogin -> "Please Login"
|
||||
State.NeedsMachineAuth -> "--"
|
||||
State.Stopped -> "Stopped"
|
||||
State.Starting -> "Starting"
|
||||
State.Running -> "Connected"
|
||||
else -> "--"
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.tailscale.ipn.ui.service.IpnModel
|
||||
import com.tailscale.ipn.ui.util.DisplayAddress
|
||||
import com.tailscale.ipn.ui.util.TimeUtil
|
||||
|
||||
data class PeerSettingInfo(val title: String, val value: String)
|
||||
|
||||
class PeerDetailsViewModel(val model: IpnModel, val nodeId: String) : ViewModel() {
|
||||
|
||||
var addresses: List<DisplayAddress> = emptyList()
|
||||
var info: List<PeerSettingInfo> = emptyList()
|
||||
|
||||
val nodeName: String
|
||||
val connectedStr: String
|
||||
val connectedColor: Color
|
||||
|
||||
init {
|
||||
val peer = model.netmap.value?.Peers?.find { it.StableID == nodeId }
|
||||
peer?.Addresses?.let {
|
||||
addresses = it.map { addr ->
|
||||
DisplayAddress(addr)
|
||||
}
|
||||
}
|
||||
|
||||
peer?.let { p ->
|
||||
info = listOf(
|
||||
PeerSettingInfo("OS", p.Hostinfo?.OS ?: ""),
|
||||
PeerSettingInfo("Key Expiry", TimeUtil().keyExpiryFromGoTime(p.KeyExpiry))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
nodeName = peer?.ComputedName ?: ""
|
||||
connectedStr = if (peer?.Online == true) "Connected" else "Not Connected"
|
||||
connectedColor = if (peer?.Online == true) Color.Green else Color.Gray
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
|
||||
package com.tailscale.ipn.ui.viewModel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.tailscale.ipn.ui.service.IpnModel
|
||||
|
||||
class SettingsViewModel(val model: IpnModel) : ViewModel() {
|
||||
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
Loading…
Reference in New Issue