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