android/ui: implement design feedback

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
ox/detectlogin
Percy Wegmann 2 months ago committed by Percy Wegmann
parent a321d84dba
commit 6e503f29a9

@ -1,6 +1,7 @@
buildscript { buildscript {
ext.kotlin_version = "1.9.22" ext.kotlin_version = "1.9.22"
ext.kotlin_compose_version = "1.5.10" ext.kotlin_compose_version = "1.5.10"
ext.accompanist_version = "0.34.0"
repositories { repositories {
google() google()
@ -88,7 +89,8 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.8.2' implementation 'androidx.activity:activity-compose:1.8.2'
implementation "com.google.accompanist:accompanist-permissions:0.34.0" implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
// Navigation dependencies. // Navigation dependencies.
def nav_version = "2.7.7" def nav_version = "2.7.7"

@ -25,7 +25,6 @@ object Permissions {
Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE,
R.string.permission_write_external_storage, R.string.permission_write_external_storage,
R.string.permission_write_external_storage_needed, R.string.permission_write_external_storage_needed,
R.string.permission_write_external_storage_granted,
)) ))
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@ -33,16 +32,10 @@ object Permissions {
Permission( Permission(
Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.POST_NOTIFICATIONS,
R.string.permission_post_notifications, R.string.permission_post_notifications,
R.string.permission_post_notifications_needed, R.string.permission_post_notifications_needed))
R.string.permission_post_notifications_granted))
} }
return result return result
} }
} }
data class Permission( data class Permission(val name: String, val title: Int, val description: Int)
val name: String,
val title: Int,
val neededDescription: Int,
val grantedDescription: Int
)

@ -3,7 +3,17 @@
package com.tailscale.ipn.ui.model package com.tailscale.ipn.ui.model
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on
import com.tailscale.ipn.ui.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.viewModel.PeerSettingInfo
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
class Tailcfg { class Tailcfg {
@Serializable @Serializable
@ -70,11 +80,14 @@ class Tailcfg {
var LastSeen: Time? = null, var LastSeen: Time? = null,
var Online: Boolean? = null, var Online: Boolean? = null,
var Capabilities: List<String>? = null, var Capabilities: List<String>? = null,
var CapMap: Map<String, JsonElement?>? = null,
var ComputedName: String, var ComputedName: String,
var ComputedNameWithHost: String var ComputedNameWithHost: String
) { ) {
val isAdmin: Boolean val isAdmin: Boolean
get() = (Capabilities ?: emptyList()).contains("https://tailscale.com/cap/is-admin") get() =
Capabilities?.contains("https://tailscale.com/cap/is-admin") == true ||
CapMap?.contains("https://tailscale.com/cap/is-admin") == true
// isExitNode reproduces the Go logic in local.go peerStatusFromNode // isExitNode reproduces the Go logic in local.go peerStatusFromNode
val isExitNode: Boolean = val isExitNode: Boolean =
@ -82,6 +95,41 @@ class Tailcfg {
val isMullvadNode: Boolean val isMullvadNode: Boolean
get() = Name.endsWith(".mullvad.ts.net.") get() = Name.endsWith(".mullvad.ts.net.")
val displayName: String
get() = ComputedName ?: ""
fun connectedOrSelfNode(nm: Netmap.NetworkMap?) =
Online == true || StableID == nm?.SelfNode?.StableID
fun connectedStrRes(nm: Netmap.NetworkMap?) =
if (connectedOrSelfNode(nm)) R.string.connected else R.string.not_connected
@Composable
fun connectedColor(nm: Netmap.NetworkMap?) =
if (connectedOrSelfNode(nm)) MaterialTheme.colorScheme.on else MaterialTheme.colorScheme.off
val nameWithoutTrailingDot = Name.trimEnd('.')
val displayAddresses: List<DisplayAddress>
get() {
var addresses = mutableListOf<DisplayAddress>()
addresses.add(DisplayAddress(nameWithoutTrailingDot))
Addresses?.let { addresses.addAll(it.map { addr -> DisplayAddress(addr) }) }
return addresses
}
val info: List<PeerSettingInfo>
get() {
val result = mutableListOf<PeerSettingInfo>()
if (Hostinfo.OS?.isNotEmpty() == true) {
result.add(
PeerSettingInfo(R.string.os, ComposableStringFormatter(Hostinfo.OS!!)),
)
}
result.add(PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(KeyExpiry)))
return result
}
} }
@Serializable @Serializable

@ -5,19 +5,5 @@ package com.tailscale.ipn.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val ts_color_light_primary = Color(0xFF232222) // TODO: replace references to these with references to material theme
val ts_color_light_secondary = Color(0xFF706E6D)
val ts_color_light_background = Color(0xFFFFFFFF)
val ts_color_light_tintedBackground = Color(0xFFF7F5F4)
val ts_color_light_blue = Color(0xFF4B70CC) val ts_color_light_blue = Color(0xFF4B70CC)
val ts_color_light_green = Color(0xFF1EA672)
var ts_color_dark_desctrutive_text = Color(0xFFFF0000)
var ts_color_light_desctrutive_text = Color(0xFFBB0000)
val ts_color_dark_primary = Color(0xFFFAF9F8)
val ts_color_dark_secondary = Color(0xFFAFACAB)
val ts_color_dark_background = Color(0xFF232222)
val ts_color_dark_tintedBackground = Color(0xFF2E2D2D)
val ts_color_dark_blue = Color(0xFF4B70CC)
var ts_color_dark_green = Color(0xFF33C27F)

@ -4,39 +4,204 @@
package com.tailscale.ipn.ui.theme package com.tailscale.ipn.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors = LightColors
val typography =
Typography(
// titleMedium is styled to be slightly larger than bodyMedium for emphasis
titleMedium =
MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp, lineHeight = 26.sp),
// bodyMedium is styled to use same line height as titleMedium to ensure even vertical
// margins in list items.
bodyMedium =
MaterialTheme.typography.bodyMedium.copy(fontSize = 16.sp, lineHeight = 26.sp))
val systemUiController = rememberSystemUiController()
DisposableEffect(systemUiController, useDarkTheme) {
systemUiController.setStatusBarColor(color = colors.surfaceContainer)
systemUiController.setNavigationBarColor(color = Color.Black)
onDispose {}
}
MaterialTheme(colorScheme = colors, typography = typography, content = content)
}
private val LightColors = private val LightColors =
lightColorScheme( lightColorScheme(
primary = ts_color_light_primary, primary = Color(0xFF4B70CC), // blue-500
onPrimary = ts_color_light_background, onPrimary = Color(0xFFFFFFFF), // white
secondary = ts_color_light_secondary, primaryContainer = Color(0xFFF0F5FF), // blue-0
onSecondary = ts_color_light_background, // primaryContainer = Color(0xFF6D94EC), // blue-400
secondaryContainer = ts_color_light_tintedBackground, onPrimaryContainer = Color(0xFF3E5DB3), // blue-600
surface = ts_color_light_background, // onPrimaryContainer = Color(0xFFFFFFFF), // white,
error = Color(0xFFB22C30), // red-500
onError = Color(0xFFFFFFFF), // white
errorContainer = Color(0xFFFEF6F3), // red-0
onErrorContainer = Color(0xFF930921), // red-600
surfaceDim = Color(0xFFF7F5F4), // gray-100
// surface = Color(0xFFF7F5F4), // gray-100
surface = Color(0xFFFFFFFF), // white,
background = Color(0xFFF7F5F4), // gray-100
surfaceBright = Color(0xFFFFFFFF), // white
surfaceContainerLowest = Color(0xFFFFFFFF), // white
surfaceContainerLow = Color(0xFFF7F5F4), // gray-100
surfaceContainer = Color(0xFFF7F5F4), // gray-100
surfaceContainerHigh = Color(0xFFF7F5F4), // gray-100
surfaceContainerHighest = Color(0xFFF7F5F4), // gray-100
onSurface = Color(0xFF232222), // gray-800
onSurfaceVariant = Color(0xFF706E6D), // gray-500
outline = Color(0xFFD9D6D5), // gray-300
outlineVariant = Color(0xFFEDEBEA), // gray-200
inverseSurface = Color(0xFF232222), // gray-800
inverseOnSurface = Color(0xFFFFFFFF), // white
scrim = Color(0xFF000000), // black
) )
private val DarkColors = val ColorScheme.warning: Color
darkColorScheme( get() = Color(0xFFD97916) // yellow-300
primary = ts_color_dark_primary,
onPrimary = ts_color_dark_background,
secondary = ts_color_dark_secondary,
onSecondary = ts_color_dark_background,
secondaryContainer = ts_color_dark_tintedBackground,
surface = ts_color_dark_background,
)
@Composable val ColorScheme.onWarning: Color
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { get() = Color(0xFFFFFFFF) // white
val colors =
if (!useDarkTheme) { val ColorScheme.warningContainer: Color
LightColors get() = Color(0xFFFFFAEE) // orange-0
} else {
DarkColors val ColorScheme.onWarningContainer: Color
} get() = Color(0xFF7E1E22) // orange-600
MaterialTheme(colorScheme = colors, content = content) val ColorScheme.success: Color
} get() = Color(0xFF0A825D) // green-400
val ColorScheme.onSuccess: Color
get() = Color(0xFFFFFFFF) // white
val ColorScheme.successContainer: Color
get() = Color(0xFFEFFEEC) // green-0
val ColorScheme.onSuccessContainer: Color
get() = Color(0xFF0E4B3B) // green-600
val ColorScheme.on: Color
get() = Color(0xFF1CA672) // green-300
val ColorScheme.off: Color
get() = Color(0xFFD9D6D5) // gray-300
val ColorScheme.link: Color
get() = onPrimaryContainer
/**
* Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons.
*/
val ColorScheme.listItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = default.containerColor,
headlineColor = default.headlineColor,
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
overlineColor = default.overlineColor,
supportingTextColor = default.supportingTextColor,
trailingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for disabled list items. */
val ColorScheme.disabledListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = default.containerColor,
headlineColor = MaterialTheme.colorScheme.disabled,
leadingIconColor = default.leadingIconColor,
overlineColor = default.overlineColor,
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
trailingIconColor = default.trailingIconColor,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for list items that should be styled as a surface container. */
val ColorScheme.surfaceContainerListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
headlineColor = MaterialTheme.colorScheme.onSurface,
leadingIconColor = MaterialTheme.colorScheme.onSurface,
overlineColor = MaterialTheme.colorScheme.onSurfaceVariant,
supportingTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
trailingIconColor = MaterialTheme.colorScheme.onSurface,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Color scheme for list items that should be styled as a primary item. */
val ColorScheme.primaryListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.primary,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}
/** Main color scheme for top app bar, styles it as a surface container. */
@OptIn(ExperimentalMaterial3Api::class)
val ColorScheme.topAppBar: TopAppBarColors
@Composable
get() =
TopAppBarDefaults.topAppBarColors()
.copy(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
)
val ColorScheme.secondaryButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
return ButtonColors(
containerColor = Color(0xFF6D94EC), // blue-400
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
val ColorScheme.disabled: Color
get() = Color(0xFFAFACAB) // gray-400

@ -1,27 +0,0 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.Role
/**
* Similar to Modifier.clickable, but if enabled == false, this adds a 75% alpha to make disabled
* items appear grayed out.
*/
@Composable
fun Modifier.clickableOrGrayedOut(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) =
if (enabled) {
clickable(onClickLabel = onClickLabel, role = role, onClick = onClick)
} else {
alpha(0.75f)
}

@ -4,7 +4,6 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -22,7 +21,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.listItem
@Composable @Composable
fun ClipboardValueView( fun ClipboardValueView(
@ -32,33 +31,30 @@ fun ClipboardValueView(
fontFamily: FontFamily = FontFamily.Monospace fontFamily: FontFamily = FontFamily.Monospace
) { ) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
Row { ListItem(
ListItem( colors = MaterialTheme.colorScheme.listItem,
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) }, modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) },
overlineContent = { title?.let { Text(it, style = MaterialTheme.typography.titleMedium) } }, overlineContent = { title?.let { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = { headlineContent = {
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontFamily = fontFamily,
maxLines = 2,
overflow = TextOverflow.Ellipsis)
},
supportingContent = {
subtitle?.let { subtitle ->
Text( Text(
text = value, subtitle,
style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp),
fontFamily = fontFamily, style = MaterialTheme.typography.bodyMedium)
maxLines = 2, }
overflow = TextOverflow.Ellipsis) },
}, trailingContent = {
supportingContent = { Icon(
subtitle?.let { subtitle -> painterResource(R.drawable.clipboard),
Text( stringResource(R.string.copy_to_clipboard),
subtitle, modifier = Modifier.width(24.dp).height(24.dp))
modifier = Modifier.padding(top = 8.dp), })
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary)
}
},
trailingContent = {
Icon(
painterResource(R.drawable.clipboard),
stringResource(R.string.copy_to_clipboard),
modifier = Modifier.width(24.dp).height(24.dp),
tint = ts_color_light_blue)
})
}
} }

@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.util
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// FeatureStateRepresentation represents the status of a feature // FeatureStateRepresentation represents the status of a feature
@ -12,7 +13,7 @@ import androidx.compose.ui.graphics.Color
// It is typically implemented as an enumeration. // It is typically implemented as an enumeration.
interface FeatureStateRepresentation { interface FeatureStateRepresentation {
@get:DrawableRes val symbolDrawable: Int @get:DrawableRes val symbolDrawable: Int
val tint: Color @get:Composable val tint: Color
@get:StringRes val title: Int @get:StringRes val title: Int
@get:StringRes val caption: Int @get:StringRes val caption: Int
} }

@ -26,7 +26,7 @@ object Lists {
@Composable @Composable
fun ItemDivider() { fun ItemDivider() {
HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
} }
} }
@ -35,7 +35,6 @@ inline fun <T> LazyListScope.itemsWithDividers(
items: List<T>, items: List<T>,
noinline key: ((item: T) -> Any)? = null, noinline key: ((item: T) -> Any)? = null,
forceLeading: Boolean = false, forceLeading: Boolean = false,
forceTrailing: Boolean = false,
crossinline contentType: (item: T) -> Any? = { _ -> null }, crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) = ) =
@ -43,9 +42,7 @@ inline fun <T> LazyListScope.itemsWithDividers(
count = items.size, count = items.size,
key = if (key != null) { index: Int -> key(items[index]) } else null, key = if (key != null) { index: Int -> key(items[index]) } else null,
contentType = { index -> contentType(items[index]) }) { contentType = { index -> contentType(items[index]) }) {
if (forceLeading && it == 0 || if (forceLeading && it == 0 || it > 0 && it < items.size) {
forceTrailing && it == items.size ||
it > 0 && it < items.size) {
Lists.ItemDivider() Lists.ItemDivider()
} }
itemContent(items[it]) itemContent(items[it])
@ -55,7 +52,6 @@ inline fun <T> LazyListScope.itemsWithDividers(
items: Array<T>, items: Array<T>,
noinline key: ((item: T) -> Any)? = null, noinline key: ((item: T) -> Any)? = null,
forceLeading: Boolean = false, forceLeading: Boolean = false,
forceTrailing: Boolean = false,
crossinline contentType: (item: T) -> Any? = { _ -> null }, crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) = itemsWithDividers(items.toList(), key, forceLeading, forceTrailing, contentType, itemContent) ) = itemsWithDividers(items.toList(), key, forceLeading, contentType, itemContent)

@ -6,37 +6,16 @@ package com.tailscale.ipn.ui.util
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.UserID import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.notifier.Notifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>) data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>> class PeerCategorizer {
class PeerCategorizer(scope: CoroutineScope) {
var peerSets: List<PeerSet> = emptyList() var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList() var lastSearchResult: List<PeerSet> = emptyList()
var searchTerm: String = "" var lastSearchTerm: String = ""
// Keep the peer sets current while the model is active fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) {
init { val peers: List<Tailcfg.Node> = netmap.Peers ?: return
scope.launch {
Notifier.netmap.collect { netmap ->
netmap?.let {
peerSets = regenerateGroupedPeers(netmap)
lastSearchResult = peerSets
}
?: run {
peerSets = emptyList()
lastSearchResult = emptyList()
}
}
}
}
private fun regenerateGroupedPeers(netmap: Netmap.NetworkMap): List<PeerSet> {
val peers: List<Tailcfg.Node> = netmap.Peers ?: return emptyList()
val selfNode = netmap.SelfNode val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>() var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
@ -56,7 +35,7 @@ class PeerCategorizer(scope: CoroutineScope) {
val me = netmap.currentUserProfile() val me = netmap.currentUserProfile()
val peerSets = peerSets =
grouped grouped
.map { (userId, peers) -> .map { (userId, peers) ->
val profile = netmap.userProfile(userId) val profile = netmap.userProfile(userId)
@ -66,11 +45,9 @@ class PeerCategorizer(scope: CoroutineScope) {
if (it.user?.ID == me?.ID) { if (it.user?.ID == me?.ID) {
"" ""
} else { } else {
it.user?.DisplayName ?: "Unknown User" it.user?.DisplayName?.lowercase() ?: "unknown user"
} }
} }
return peerSets
} }
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> { fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
@ -78,14 +55,17 @@ class PeerCategorizer(scope: CoroutineScope) {
return peerSets return peerSets
} }
if (searchTerm == this.searchTerm) { if (searchTerm == this.lastSearchTerm) {
return lastSearchResult return lastSearchResult
} }
// We can optimize out typing... If the search term starts with the last search term, we can // We can optimize out typing... If the search term starts with the last search term, we can
// just search the last result // just search the last result
val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets val setsToSearch =
this.searchTerm = searchTerm if (this.lastSearchTerm.isNotEmpty() && searchTerm.startsWith(this.lastSearchTerm))
lastSearchResult
else peerSets
this.lastSearchTerm = searchTerm
val matchingSets = val matchingSets =
setsToSearch setsToSearch
@ -107,7 +87,7 @@ class PeerCategorizer(scope: CoroutineScope) {
} }
} }
.filterNotNull() .filterNotNull()
lastSearchResult = matchingSets
return matchingSets return matchingSets
} }
} }

@ -3,11 +3,8 @@
package com.tailscale.ipn.ui.util package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -15,12 +12,5 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun settingsRowModifier(): Modifier { fun settingsRowModifier(): Modifier {
return Modifier.clip(shape = RoundedCornerShape(8.dp)) return Modifier.clip(shape = RoundedCornerShape(8.dp)).fillMaxWidth()
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()
}
@Composable
fun defaultPaddingModifier(): Modifier {
return Modifier.padding(8.dp)
} }

@ -47,6 +47,7 @@ fun AboutView(nav: BackNavigation) {
.padding(15.dp), .padding(15.dp),
painter = painterResource(id = R.drawable.ic_tile), painter = painterResource(id = R.drawable.ic_tile),
contentDescription = stringResource(R.string.app_icon_content_description)) contentDescription = stringResource(R.string.app_icon_content_description))
Column( Column(
verticalArrangement = verticalArrangement =
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
@ -54,28 +55,23 @@ fun AboutView(nav: BackNavigation) {
Text( Text(
stringResource(R.string.about_view_title), stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize, fontSize = MaterialTheme.typography.titleLarge.fontSize)
color = MaterialTheme.colorScheme.primary)
Text( Text(
text = BuildConfig.VERSION_NAME, text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight, fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
fontSize = MaterialTheme.typography.bodyMedium.fontSize, fontSize = MaterialTheme.typography.bodyMedium.fontSize)
color = MaterialTheme.colorScheme.secondary)
}
Column(
verticalArrangement =
Arrangement.spacedBy(space = 4.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally) {
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
} }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
OpenURLButton(stringResource(R.string.acknowledgements), Links.LICENSES_URL)
OpenURLButton(stringResource(R.string.privacy_policy), Links.PRIVACY_POLICY_URL)
OpenURLButton(stringResource(R.string.terms_of_service), Links.TERMS_URL)
}
Text( Text(
stringResource(R.string.about_view_footnotes), stringResource(R.string.about_view_footnotes),
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize, fontSize = MaterialTheme.typography.labelMedium.fontSize,
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
} }
} }

@ -3,15 +3,17 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -22,22 +24,20 @@ import com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@Composable @Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) { fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) {
Box( Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) {
contentAlignment = Alignment.Center, var modifier = Modifier.size((size * .8f).dp)
action?.let {
modifier = modifier =
Modifier.size(size.dp) modifier.clickable(
.clip(CircleShape) interactionSource = remember { MutableInteractionSource() },
.background(MaterialTheme.colorScheme.tertiaryContainer)) { indication = rememberRipple(bounded = false),
Icon( onClick = action)
imageVector = Icons.Default.Person, }
contentDescription = null, Icon(imageVector = Icons.Default.Person, contentDescription = null, modifier = modifier)
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp))
profile?.UserProfile?.ProfilePicURL?.let { url -> profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage( AsyncImage(model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)
model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null) }
} }
}
} }

@ -26,9 +26,8 @@ import androidx.compose.ui.text.withStyle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.util.ClipboardValueView import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.BugReportViewModel import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@Composable @Composable
@ -40,25 +39,16 @@ fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel())
Column(modifier = Modifier.padding(innerPadding).fillMaxWidth().fillMaxHeight()) { Column(modifier = Modifier.padding(innerPadding).fillMaxWidth().fillMaxHeight()) {
ListItem( ListItem(
headlineContent = { headlineContent = {
ClickableText( ClickableText(text = contactText(), onClick = { handler.openUri(Links.SUPPORT_URL) })
text = contactText(),
modifier = Modifier.fillMaxWidth(),
onClick = { handler.openUri(Links.SUPPORT_URL) })
}) })
Lists.SectionDivider()
ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id)) ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id))
Lists.SectionDivider()
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
text = stringResource(id = R.string.bug_report_id_desc), text = stringResource(id = R.string.bug_report_id_desc),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left, textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall)
}) })
} }
@ -68,20 +58,19 @@ fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel())
@Composable @Composable
fun contactText(): AnnotatedString { fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { append(stringResource(id = R.string.bug_report_instructions_prefix))
append(stringResource(id = R.string.bug_report_instructions_prefix))
}
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle( withStyle(
style = SpanStyle(color = ts_color_light_blue, textDecoration = TextDecoration.Underline)) { style =
SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.bug_report_instructions_linktext)) append(stringResource(id = R.string.bug_report_instructions_linktext))
} }
pop() pop()
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { append(stringResource(id = R.string.bug_report_instructions_suffix))
append(stringResource(id = R.string.bug_report_instructions_suffix))
}
} }
return annotatedString return annotatedString
} }

@ -11,30 +11,23 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.link
@Composable @Composable
fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
Button( Button(
onClick = onClick, onClick = onClick,
colors =
ButtonColors(
containerColor = ts_color_light_blue,
contentColor = Color.White,
disabledContainerColor = MaterialTheme.colorScheme.secondary,
disabledContentColor = MaterialTheme.colorScheme.onSecondary),
contentPadding = PaddingValues(vertical = 12.dp), contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
content = content) content = content)
@ -44,13 +37,14 @@ fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() ->
fun OpenURLButton(title: String, url: String) { fun OpenURLButton(title: String, url: String) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
Button( TextButton(onClick = { handler.openUri(url) }) {
onClick = { handler.openUri(url) }, Text(
content = { Text(title) }, title,
colors = style = MaterialTheme.typography.bodyMedium,
ButtonDefaults.buttonColors( color = MaterialTheme.colorScheme.link,
contentColor = MaterialTheme.colorScheme.secondary, textDecoration = TextDecoration.Underline,
containerColor = MaterialTheme.colorScheme.secondaryContainer)) )
}
} }
@Composable @Composable
@ -60,7 +54,6 @@ fun ClearButton(onClick: () -> Unit) {
} }
} }
@Composable @Composable
fun CloseButton() { fun CloseButton() {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current

@ -3,7 +3,6 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -23,16 +21,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.disabledListItem
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.clickableOrGrayedOut
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected import com.tailscale.ipn.ui.viewModel.selected
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun ExitNodePicker( fun ExitNodePicker(
nav: ExitNodePickerNav, nav: ExitNodePickerNav,
@ -47,15 +45,7 @@ fun ExitNodePicker(
val anyActive = model.anyActive.collectAsState() val anyActive = model.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
stickyHeader { item(key = "header") {
RunAsExitNodeItem(nav = nav, viewModel = model)
Lists.ItemDivider()
SettingRow(model.allowLANAccessSetting)
}
item(key = "none") {
Lists.SectionDivider()
ExitNodeItem( ExitNodeItem(
model, model,
ExitNodePickerViewModel.ExitNode( ExitNodePickerViewModel.ExitNode(
@ -63,19 +53,26 @@ fun ExitNodePicker(
online = true, online = true,
selected = !anyActive.value, selected = !anyActive.value,
)) ))
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model)
} }
item { Lists.ItemDivider() } item(key = "divider1") { Lists.SectionDivider() }
itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) } itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }
item { Lists.SectionDivider() }
if (mullvadExitNodeCount > 0) { if (mullvadExitNodeCount > 0) {
item(key = "mullvad") { item(key = "mullvad") {
Lists.SectionDivider()
MullvadItem(nav, mullvadExitNodeCount, mullvadExitNodesByCountryCode.selected) MullvadItem(nav, mullvadExitNodeCount, mullvadExitNodesByCountryCode.selected)
} }
} }
// TODO: make sure this actually works, and if not, leave it out for now
item(key = "allowLANAccess") {
Lists.SectionDivider()
SettingRow(model.allowLANAccessSetting)
}
} }
} }
} }
@ -87,16 +84,17 @@ fun ExitNodeItem(
node: ExitNodePickerViewModel.ExitNode, node: ExitNodePickerViewModel.ExitNode,
) { ) {
Box { Box {
// TODO: add disabled styling var modifier: Modifier = Modifier
if (node.online) {
modifier = modifier.clickable { viewModel.setExitNode(node) }
}
ListItem( ListItem(
modifier = modifier = modifier,
Modifier.clickableOrGrayedOut(enabled = node.online) { viewModel.setExitNode(node) }, colors =
if (node.online) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = { headlineContent = {
Text( Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
node.city.ifEmpty { node.label },
style =
if (node.online) MaterialTheme.typography.titleMedium
else MaterialTheme.typography.bodyMedium)
}, },
supportingContent = { supportingContent = {
if (!node.online) if (!node.online)
@ -120,7 +118,7 @@ fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
headlineContent = { headlineContent = {
Text( Text(
stringResource(R.string.mullvad_exit_nodes), stringResource(R.string.mullvad_exit_nodes),
style = MaterialTheme.typography.titleMedium) style = MaterialTheme.typography.bodyMedium)
}, },
supportingContent = { supportingContent = {
Text( Text(
@ -147,7 +145,7 @@ fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel
stringResource(id = R.string.run_as_exit_node), stringResource(id = R.string.run_as_exit_node),
style = MaterialTheme.typography.bodyMedium) style = MaterialTheme.typography.bodyMedium)
}, },
trailingContent = { supportingContent = {
if (isRunningExitNode) { if (isRunningExitNode) {
Text(stringResource(R.string.enabled)) Text(stringResource(R.string.enabled))
} else { } else {

@ -45,13 +45,11 @@ fun MDMSettingView(setting: MDMSetting<*>) {
Text( Text(
setting.key, setting.key,
fontSize = MaterialTheme.typography.labelSmall.fontSize, fontSize = MaterialTheme.typography.labelSmall.fontSize,
color = MaterialTheme.colorScheme.tertiary,
fontFamily = FontFamily.Monospace) fontFamily = FontFamily.Monospace)
}, },
trailingContent = { trailingContent = {
Text( Text(
value.toString(), value.toString(),
color = MaterialTheme.colorScheme.secondary,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
maxLines = 1, maxLines = 1,
fontWeight = FontWeight.SemiBold) fontWeight = FontWeight.SemiBold)

@ -3,6 +3,7 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -14,6 +15,8 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
@ -28,6 +31,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBar
@ -39,12 +43,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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.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.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
@ -55,15 +62,18 @@ 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.Permission import com.tailscale.ipn.ui.model.Permission
import com.tailscale.ipn.ui.model.Permissions import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.primaryListItem
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView // Navigation actions for the MainView
data class MainViewNavigation( data class MainViewNavigation(
@ -82,51 +92,58 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
verticalArrangement = Arrangement.Center) { verticalArrangement = Arrangement.Center) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null) val user = viewModel.loggedInUser.collectAsState(initial = null)
val stateVal = viewModel.stateRes.collectAsState(initial = R.string.placeholder)
val stateStr = stringResource(id = stateVal.value)
val username = viewModel.userName
Row( ListItem(
modifier = colors = MaterialTheme.colorScheme.surfaceContainerListItem,
Modifier.fillMaxWidth() leadingContent = {
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 16.dp)
.padding(top = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false) val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NoState) { TintedSwitch(
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) onCheckedChange = { viewModel.toggleVpn() },
Spacer(Modifier.size(3.dp)) checked = isOn.value,
enabled = state.value != Ipn.State.NoState)
},
headlineContent = {
if (username.isNotEmpty()) {
Text(
text = username,
style = MaterialTheme.typography.titleMedium.copy(lineHeight = 20.sp))
} }
},
StateDisplay(viewModel.stateRes, viewModel.userName) supportingContent = {
if (username.isNotEmpty()) {
Box( Text(
modifier = text = stateStr,
Modifier.weight(1f).clickable { navigation.onNavigateToSettings() }, style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp))
contentAlignment = Alignment.CenterEnd) { } else {
when (user.value) { Text(
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() } text = stateStr,
else -> Avatar(profile = user.value, size = 36) style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp))
} }
} },
} trailingContent = {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
when (user.value) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else ->
Avatar(profile = user.value, size = 36) {
navigation.onNavigateToSettings()
}
}
}
})
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
PromptPermissionsIfNecessary(permissions = Permissions.all) PromptPermissionsIfNecessary(permissions = Permissions.all)
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
Row(
modifier =
Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(top = 10.dp, bottom = 20.dp)) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList( PeerList(
searchTerm = viewModel.searchTerm, viewModel = viewModel,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) }) onSearch = { viewModel.searchPeers(it) })
} }
@ -148,67 +165,55 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val exitNodeId = prefs.value?.ExitNodeID val exitNodeId = prefs.value?.ExitNodeID
val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } } val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
val location = peer?.Hostinfo?.Location val location = peer?.Hostinfo?.Location
val name = peer?.Name val name = peer?.ComputedName
val active = peer != null
Box( Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
modifier = Box(
Modifier.padding(horizontal = 16.dp) modifier =
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 16.dp)
.background(MaterialTheme.colorScheme.background) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.fillMaxWidth()) { .fillMaxWidth()) {
ListItem( ListItem(
modifier = Modifier.clickable { navAction() }, modifier = Modifier.clickable { navAction() },
headlineContent = { colors =
Text( if (active) MaterialTheme.colorScheme.primaryListItem
stringResource(R.string.exit_node), else ListItemDefaults.colors(),
style = MaterialTheme.typography.titleMedium, overlineContent = {
)
},
supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = stringResource(R.string.exit_node),
location?.let { "${it.CountryCode?.flag()} ${it.Country} - ${it.City}" } style = MaterialTheme.typography.bodySmall,
?: name
?: stringResource(id = R.string.none),
style = MaterialTheme.typography.bodyLarge)
Icon(
Icons.Outlined.ArrowDropDown,
null,
) )
} },
}, headlineContent = {
trailingContent = { Row(verticalAlignment = Alignment.CenterVertically) {
if (peer != null) { Text(
Button(onClick = { viewModel.disableExitNode() }) { text =
Text(stringResource(R.string.disable)) location?.let { "${it.CountryCode?.flag()} ${it.Country} - ${it.City}" }
?: name
?: stringResource(id = R.string.none),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Icon(
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null,
tint =
if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} },
}) trailingContent = {
} if (peer != null) {
} Button(
colors = MaterialTheme.colorScheme.secondaryButton,
@Composable onClick = { viewModel.disableExitNode() }) {
fun StateDisplay(state: StateFlow<Int>, tailnet: String) { Text(stringResource(R.string.disable))
val stateVal = state.collectAsState(initial = R.string.placeholder) }
val stateStr = stringResource(id = stateVal.value) }
})
Column(modifier = Modifier.padding(7.dp)) { }
when (tailnet.isEmpty()) {
false -> {
Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text(
text = stateStr,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary)
}
true -> {
Text(
text = stateStr,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary)
}
}
} }
} }
@ -227,10 +232,10 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
@Composable @Composable
fun StartingView() { fun StartingView() {
Column( Column(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.secondaryContainer), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally) {
TailscaleLogoView(animated = true, Modifier.size(72.dp)) TailscaleLogoView(animated = true, Modifier.size(40.dp))
} }
} }
@ -242,81 +247,76 @@ fun ConnectView(
loginAction: () -> Unit loginAction: () -> Unit
) { ) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column( Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
horizontalAlignment = Alignment.CenterHorizontally, Column(
modifier = modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(),
Modifier.background(MaterialTheme.colorScheme.secondaryContainer).fillMaxWidth()) { verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
Column( horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(), ) {
verticalArrangement = if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) {
Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically), Icon(
horizontalAlignment = Alignment.CenterHorizontally, painter = painterResource(id = R.drawable.power),
) { contentDescription = null,
if (state != Ipn.State.NeedsLogin && user != null && !user.isEmpty()) { modifier = Modifier.size(40.dp),
Icon( tint = MaterialTheme.colorScheme.disabled)
painter = painterResource(id = R.drawable.power), Text(
contentDescription = null, text = stringResource(id = R.string.not_connected),
modifier = Modifier.size(48.dp), fontSize = MaterialTheme.typography.titleMedium.fontSize,
tint = MaterialTheme.colorScheme.secondary) fontWeight = FontWeight.SemiBold,
Text( textAlign = TextAlign.Center,
text = stringResource(id = R.string.not_connected), fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
fontSize = MaterialTheme.typography.titleMedium.fontSize, val tailnetName = user.NetworkProfile?.DomainName ?: ""
fontWeight = FontWeight.SemiBold, Text(
color = MaterialTheme.colorScheme.primary, buildAnnotatedString {
textAlign = TextAlign.Center, append(stringResource(id = R.string.connect_to_tailnet_prefix))
fontFamily = MaterialTheme.typography.titleMedium.fontFamily) pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
val tailnetName = user.NetworkProfile?.DomainName ?: "" append(tailnetName)
Text( pop()
stringResource(id = R.string.connect_to_tailnet, tailnetName), append(stringResource(id = R.string.connect_to_tailnet_suffix))
fontSize = MaterialTheme.typography.titleMedium.fontSize, },
fontWeight = FontWeight.Normal, fontSize = MaterialTheme.typography.titleMedium.fontSize,
color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = connectAction) { PrimaryActionButton(onClick = connectAction) {
Text( Text(
text = stringResource(id = R.string.connect), text = stringResource(id = R.string.connect),
fontSize = MaterialTheme.typography.titleMedium.fontSize) fontSize = MaterialTheme.typography.titleMedium.fontSize)
} }
} else { } else {
TailscaleLogoView(modifier = Modifier.size(50.dp)) TailscaleLogoView(modifier = Modifier.size(50.dp))
Spacer(modifier = Modifier.size(1.dp)) Spacer(modifier = Modifier.size(1.dp))
Text( Text(
text = stringResource(id = R.string.welcome_to_tailscale), text = stringResource(id = R.string.welcome_to_tailscale),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center)
textAlign = TextAlign.Center) Text(
Text( stringResource(R.string.login_to_join_your_tailnet),
stringResource(R.string.login_to_join_your_tailnet), style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.titleSmall, textAlign = TextAlign.Center)
color = MaterialTheme.colorScheme.secondary, Spacer(modifier = Modifier.size(1.dp))
textAlign = TextAlign.Center) PrimaryActionButton(onClick = loginAction) {
Spacer(modifier = Modifier.size(1.dp)) Text(
PrimaryActionButton(onClick = loginAction) { text = stringResource(id = R.string.log_in),
Text( fontSize = MaterialTheme.typography.titleMedium.fontSize)
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
} }
} }
}
}
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun PeerList( fun PeerList(
searchTerm: StateFlow<String>, viewModel: MainViewModel,
peers: StateFlow<List<PeerSet>>,
state: StateFlow<Ipn.State>,
selfPeer: StableNodeID,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit onSearch: (String) -> Unit
) { ) {
val peerList = peers.collectAsState(initial = emptyList<PeerSet>()) val peerList = viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by searchTerm.collectAsState(initial = "") val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val stateVal = state.collectAsState(initial = Ipn.State.NoState) val netmap = viewModel.netmap.collectAsState()
SearchBar( SearchBar(
query = searchTermStr, query = searchTermStr,
@ -333,15 +333,25 @@ fun PeerList(
shadowElevation = 0.dp, shadowElevation = 0.dp,
colors = colors =
SearchBarDefaults.colors( SearchBarDefaults.colors(
containerColor = Color.Transparent, dividerColor = Color.Transparent), containerColor = MaterialTheme.colorScheme.surface,
dividerColor = MaterialTheme.colorScheme.outline),
modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth()) {
LazyColumn( LazyColumn(
modifier = modifier = Modifier.fillMaxSize(),
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.secondaryContainer),
) { ) {
var first = true
peerList.value.forEach { peerSet -> peerList.value.forEach { peerSet ->
item { if (!first) {
item(key = "spacer_${peerSet.user?.DisplayName}") {
Lists.ItemDivider()
Spacer(Modifier.height(24.dp))
}
}
first = false
stickyHeader {
ListItem( ListItem(
modifier = Modifier.heightIn(max = 48.dp),
headlineContent = { headlineContent = {
Text( Text(
text = text =
@ -350,35 +360,28 @@ fun PeerList(
fontWeight = FontWeight.SemiBold) fontWeight = FontWeight.SemiBold)
}) })
} }
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem( ListItem(
modifier = Modifier.clickable { onNavigateToPeerDetails(peer) }, modifier = Modifier.clickable { onNavigateToPeerDetails(peer) },
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list
// unless you're connected.
val isSelfAndRunning =
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color =
if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green
} else {
Color.Gray
}
Box( Box(
modifier = modifier =
Modifier.size(10.dp) Modifier.padding(top = 2.dp)
.size(10.dp)
.background( .background(
color = color, shape = RoundedCornerShape(percent = 50))) {} color = peer.connectedColor(netmap.value),
Spacer(modifier = Modifier.size(6.dp)) shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
} }
}, },
supportingContent = { supportingContent = {
Text( Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "", text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium)
color = MaterialTheme.colorScheme.secondary)
}) })
} }
} }
@ -393,7 +396,7 @@ fun PromptPermissionsIfNecessary(permissions: List<Permission>) {
val state = rememberPermissionState(permission.name) val state = rememberPermissionState(permission.name)
if (!state.status.isGranted && !state.status.shouldShowRationale) { if (!state.status.isGranted && !state.status.shouldShowRationale) {
// We don't have the permission and can ask for it // We don't have the permission and can ask for it
ErrorDialog(title = permission.title, message = permission.neededDescription) { ErrorDialog(title = permission.title, message = permission.description) {
state.launchPermissionRequest() state.launchPermissionRequest()
} }
} }

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -37,7 +38,9 @@ fun MullvadExitNodePicker(
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold( Scaffold(
topBar = { topBar = {
Header(titleText = "${countryCode.flag()} ${any.country}", onBack = nav.onNavigateBack) Header(
title = { Text("${countryCode.flag()} ${any.country}") },
onBack = nav.onNavigateBack)
}) { innerPadding -> }) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) { if (nodes.size > 1) {
@ -51,7 +54,7 @@ fun MullvadExitNodePicker(
online = bestAvailableNode.online, online = bestAvailableNode.online,
selected = false, selected = false,
)) ))
Lists.ItemDivider() Lists.SectionDivider()
} }
} }

@ -70,7 +70,7 @@ fun MullvadExitNodePickerList(
) )
}, },
headlineContent = { headlineContent = {
Text(first.country, style = MaterialTheme.typography.titleMedium) Text(first.country, style = MaterialTheme.typography.bodyMedium)
}, },
supportingContent = { supportingContent = {
Text( Text(

@ -9,31 +9,39 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.ts_color_light_blue
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PeerDetails( fun PeerDetails(
nav: BackNavigation, nav: BackNavigation,
@ -41,45 +49,59 @@ fun PeerDetails(
model: PeerDetailsViewModel = model: PeerDetailsViewModel =
viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir)) viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir))
) { ) {
Scaffold(topBar = { Header(title = R.string.peer_details, onBack = nav.onBack) }) { innerPadding model.netmap.collectAsState().value?.let { netmap ->
-> model.node.collectAsState().value?.let { node ->
Column( Scaffold(
modifier = topBar = {
Modifier.fillMaxWidth() Header(
.padding(innerPadding) title = {
.padding(horizontal = 16.dp) Column {
.padding(top = 22.dp), Text(
) { text = node.displayName,
Text( style = MaterialTheme.typography.titleMedium.copy(lineHeight = 20.sp),
text = model.nodeName, color = MaterialTheme.colorScheme.onSurface)
style = MaterialTheme.typography.titleLarge, Row(verticalAlignment = Alignment.CenterVertically) {
color = MaterialTheme.colorScheme.primary) Box(
Row(verticalAlignment = Alignment.CenterVertically) { modifier =
Box( Modifier.size(8.dp)
modifier = .background(
Modifier.size(8.dp) color = node.connectedColor(netmap),
.background( shape = RoundedCornerShape(percent = 50))) {}
color = model.connectedColor, shape = RoundedCornerShape(percent = 50))) {} Spacer(modifier = Modifier.size(8.dp))
Spacer(modifier = Modifier.size(8.dp)) Text(
Text( text = stringResource(id = node.connectedStrRes(netmap)),
text = stringResource(id = model.connectedStrRes), style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
color = MaterialTheme.colorScheme.primary) }
} }
Column(modifier = Modifier.fillMaxHeight()) { },
Text( onBack = { nav.onBack() })
text = stringResource(id = R.string.addresses_section), },
style = MaterialTheme.typography.titleMedium, ) { innerPadding ->
color = MaterialTheme.colorScheme.primary) LazyColumn(
modifier = Modifier.padding(innerPadding),
) {
item(key = "tailscaleAddresses") {
Box(
modifier =
Modifier.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
text = stringResource(R.string.tailscale_addresses),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Column(modifier = settingsRowModifier()) { itemsWithDividers(node.displayAddresses, key = { it.address }) {
model.addresses.forEach { AddressRow(address = it.address, type = it.typeString) } AddressRow(address = it.address, type = it.typeString)
} }
Spacer(modifier = Modifier.size(16.dp)) item(key = "infoDivider") { Lists.SectionDivider() }
Column(modifier = settingsRowModifier()) { itemsWithDividers(node.info, key = { "info_${it.titleRes}" }) {
model.info.forEach {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString()) ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
} }
} }
@ -92,33 +114,21 @@ fun PeerDetails(
fun AddressRow(address: String, type: String) { fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
Row( ListItem(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(address)) },
modifier = colors = MaterialTheme.colorScheme.listItem,
Modifier.padding(horizontal = 8.dp, vertical = 8.dp) headlineContent = { Text(text = address) },
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) { supportingContent = { Text(text = type) },
Column { trailingContent = {
Text(text = address) // TODO: there is some overlap with other uses of clipboard, DRY
Text( Icon(painter = painterResource(id = R.drawable.clipboard), null, tint = ts_color_light_blue)
text = type, })
fontSize = MaterialTheme.typography.labelLarge.fontSize,
color = MaterialTheme.colorScheme.secondary)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Icon(
painter = painterResource(id = R.drawable.clipboard),
null,
tint = ts_color_light_blue)
}
}
} }
@Composable @Composable
fun ValueRow(title: String, value: String) { fun ValueRow(title: String, value: String) {
Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp).fillMaxWidth()) { ListItem(
Text(text = title) colors = MaterialTheme.colorScheme.listItem,
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { headlineContent = { Text(text = title) },
Text(text = value, color = MaterialTheme.colorScheme.secondary) supportingContent = { Text(text = value) })
}
}
} }

@ -20,19 +20,20 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.on
@Composable @Composable
fun PeerView( fun PeerView(
peer: Tailcfg.Node, peer: Tailcfg.Node,
selfPeer: String? = null, selfPeer: String? = null,
stateVal: Ipn.State? = null, stateVal: Ipn.State? = null,
disabled: Boolean = false,
subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" }, subtitle: () -> String = { peer.Addresses?.first()?.split("/")?.first() ?: "" },
onClick: (Tailcfg.Node) -> Unit = {}, onClick: (Tailcfg.Node) -> Unit = {},
trailingContent: @Composable () -> Unit = {} trailingContent: @Composable () -> Unit = {}
) { ) {
val textColor = if (disabled) Color.Gray else MaterialTheme.colorScheme.primary val disabled = !(peer.Online ?: false)
val textColor = if (disabled) MaterialTheme.colorScheme.onSurfaceVariant else Color.Unspecified
ListItem( ListItem(
modifier = Modifier.clickable { onClick(peer) }, modifier = Modifier.clickable { onClick(peer) },
@ -43,9 +44,9 @@ fun PeerView(
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running) val isSelfAndRunning = (peer.StableID == selfPeer && stateVal == Ipn.State.Running)
val color: Color = val color: Color =
if ((peer.Online == true) || isSelfAndRunning) { if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green MaterialTheme.colorScheme.on
} else { } else {
Color.Gray MaterialTheme.colorScheme.off
} }
Box( Box(
modifier = modifier =

@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -30,7 +29,8 @@ import com.tailscale.ipn.ui.util.itemsWithDividers
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) { fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) {
Scaffold(topBar = { Header(title = R.string.permissions, onBack = nav.onBack) }) { innerPadding -> Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = nav.onBack) }) { innerPadding
->
val permissions = Permissions.all val permissions = Permissions.all
val permissionStates = val permissionStates =
rememberMultiplePermissionsState(permissions = permissions.map { it.name }) rememberMultiplePermissionsState(permissions = permissions.map { it.name })
@ -54,20 +54,7 @@ fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) {
headlineContent = { headlineContent = {
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium) Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
}, },
supportingContent = { supportingContent = { Text(stringResource(permission.description)) },
Text(
stringResource(
if (state.status.isGranted) permission.grantedDescription
else permission.neededDescription))
},
trailingContent = {
if (!state.status.isGranted) {
Icon(
Icons.AutoMirrored.Outlined.KeyboardArrowRight,
modifier = Modifier.size(24.dp),
contentDescription = stringResource(R.string.more))
}
},
) )
} }
} }

@ -45,8 +45,7 @@ fun RunExitNodeView(
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = verticalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
modifier = Modifier.padding(innerPadding).padding(16.dp).fillMaxHeight()) { modifier = Modifier.padding(innerPadding).padding(16.dp).fillMaxHeight()) {
RunExitNodeGraphic() RunExitNodeGraphic()
@ -83,11 +82,7 @@ fun RunExitNodeView(
fun RunExitNodeGraphic() { fun RunExitNodeGraphic() {
@Composable @Composable
fun ArrowForward() { fun ArrowForward() {
Icon( Icon(Icons.AutoMirrored.Outlined.ArrowForward, "Arrow Forward", modifier = Modifier.size(24.dp))
Icons.AutoMirrored.Outlined.ArrowForward,
"Arrow Forward",
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(24.dp))
} }
Row( Row(

@ -6,8 +6,6 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@ -22,13 +20,14 @@ import androidx.compose.ui.platform.LocalUriHandler
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.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.BuildConfig import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.ts_color_dark_desctrutive_text import com.tailscale.ipn.ui.theme.link
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.Setting import com.tailscale.ipn.ui.viewModel.Setting
import com.tailscale.ipn.ui.viewModel.SettingType import com.tailscale.ipn.ui.viewModel.SettingType
@ -47,8 +46,9 @@ fun SettingsView(
val managedBy = viewModel.managedBy.collectAsState().value val managedBy = viewModel.managedBy.collectAsState().value
Scaffold( Scaffold(
topBar = { Header(title = R.string.settings_title, onBack = settingsNav.onBackPressed) }) { topBar = {
innerPadding -> Header(titleRes = R.string.settings_title, onBack = settingsNav.onBackPressed)
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Column(modifier = Modifier.padding(innerPadding)) {
UserView( UserView(
profile = user, profile = user,
@ -56,10 +56,10 @@ fun SettingsView(
onClick = viewModel.navigation.onNavigateToUserSwitcher) onClick = viewModel.navigation.onNavigateToUserSwitcher)
if (isAdmin) { if (isAdmin) {
Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) } AdminTextView { handler.openUri(Links.ADMIN_URL) }
} }
Lists.SectionDivider()
SettingRow(viewModel.dns) SettingRow(viewModel.dns)
Lists.ItemDivider() Lists.ItemDivider()
@ -68,21 +68,22 @@ fun SettingsView(
Lists.ItemDivider() Lists.ItemDivider()
SettingRow(viewModel.permissions) SettingRow(viewModel.permissions)
Lists.ItemDivider() managedBy?.let {
SettingRow(viewModel.about) Lists.ItemDivider()
SettingRow(it)
}
Lists.ItemDivider() Lists.SectionDivider()
SettingRow(viewModel.bugReport) SettingRow(viewModel.bugReport)
Lists.ItemDivider()
SettingRow(viewModel.about)
// TODO: put a heading for the debug section
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Lists.ItemDivider() Lists.SectionDivider()
SettingRow(viewModel.mdmDebug) SettingRow(viewModel.mdmDebug)
} }
managedBy?.let {
Lists.ItemDivider()
SettingRow(it)
}
} }
} }
} }
@ -105,13 +106,12 @@ private fun TextRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value val enabled = setting.enabled.collectAsState().value
ListItem( ListItem(
modifier = Modifier.clickable { if (enabled) setting.onClick() }, modifier = Modifier.clickable { if (enabled) setting.onClick() },
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { headlineContent = {
Text( Text(
setting.title ?: stringResource(setting.titleRes), setting.title ?: stringResource(setting.titleRes),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = color = if (setting.destructive) MaterialTheme.colorScheme.error else Color.Unspecified)
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
}, },
) )
} }
@ -122,6 +122,7 @@ private fun SwitchRow(setting: Setting) {
val swVal = setting.isOn?.collectAsState()?.value ?: false val swVal = setting.isOn?.collectAsState()?.value ?: false
ListItem( ListItem(
modifier = Modifier.clickable { if (enabled) setting.onClick() }, modifier = Modifier.clickable { if (enabled) setting.onClick() },
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { headlineContent = {
Text( Text(
setting.title ?: stringResource(setting.titleRes), setting.title ?: stringResource(setting.titleRes),
@ -137,34 +138,37 @@ private fun SwitchRow(setting: Setting) {
private fun NavRow(setting: Setting) { private fun NavRow(setting: Setting) {
ListItem( ListItem(
modifier = Modifier.clickable { setting.onClick() }, modifier = Modifier.clickable { setting.onClick() },
colors = MaterialTheme.colorScheme.listItem,
headlineContent = { headlineContent = {
Text( Text(
setting.title ?: stringResource(setting.titleRes), setting.title ?: stringResource(setting.titleRes),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium)
color =
if (setting.destructive) ts_color_dark_desctrutive_text
else MaterialTheme.colorScheme.primary)
}) })
} }
@Composable @Composable
fun AdminTextView(onNavigateToAdminConsole: () -> Unit) { fun AdminTextView(onNavigateToAdminConsole: () -> Unit) {
val adminStr = buildAnnotatedString { val adminStr = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)) {
append(stringResource(id = R.string.settings_admin_prefix)) append(stringResource(id = R.string.settings_admin_prefix))
} }
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(style = SpanStyle(color = Color.Blue)) { withStyle(
append(stringResource(id = R.string.settings_admin_link)) style =
} SpanStyle(
color = MaterialTheme.colorScheme.link,
textDecoration = TextDecoration.Underline)) {
append(stringResource(id = R.string.settings_admin_link))
}
pop() pop()
} }
Column(modifier = Modifier.padding(horizontal = 12.dp)) { ListItem(
ClickableText( headlineContent = {
text = adminStr, ClickableText(
style = MaterialTheme.typography.bodySmall, text = adminStr,
onClick = { onNavigateToAdminConsole() }) style = MaterialTheme.typography.bodyMedium,
} onClick = { onNavigateToAdminConsole() })
})
} }

@ -5,12 +5,15 @@ package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -18,11 +21,12 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.topAppBar
import com.tailscale.ipn.ui.theme.ts_color_light_blue import com.tailscale.ipn.ui.theme.ts_color_light_blue
data class BackNavigation( data class BackNavigation(
@ -34,29 +38,37 @@ data class BackNavigation(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Header( fun Header(
@StringRes title: Int = 0, @StringRes titleRes: Int = 0,
titleText: String? = null, title: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
onBack: (() -> Unit)? = null onBack: (() -> Unit)? = null
) { ) {
TopAppBar( TopAppBar(
colors = title = {
TopAppBarDefaults.topAppBarColors( title?.let { title() }
containerColor = MaterialTheme.colorScheme.surfaceContainer, ?: Text(
titleContentColor = MaterialTheme.colorScheme.primary, stringResource(titleRes),
), style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface)
},
colors = MaterialTheme.colorScheme.topAppBar,
actions = actions, actions = actions,
title = { Text(titleText ?: stringResource(title)) },
navigationIcon = { onBack?.let { BackArrow(action = it) } }, navigationIcon = { onBack?.let { BackArrow(action = it) } },
) )
} }
@Composable @Composable
fun BackArrow(action: () -> Unit) { fun BackArrow(action: () -> Unit) {
Icon( Box(modifier = Modifier.padding(start = 8.dp, end = 8.dp)) {
Icons.AutoMirrored.Filled.ArrowBack, Icon(
null, Icons.AutoMirrored.Filled.ArrowBack,
modifier = Modifier.clickable { action() }.padding(start = 15.dp, end = 20.dp)) null,
modifier =
Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = { action() }))
}
} }
@Composable @Composable
@ -68,8 +80,6 @@ fun CheckedIndicator() {
fun SimpleActivityIndicator(size: Int = 32) { fun SimpleActivityIndicator(size: Int = 32) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.width(size.dp), modifier = Modifier.width(size.dp),
color = ts_color_light_blue,
trackColor = MaterialTheme.colorScheme.secondary,
) )
} }

@ -96,12 +96,10 @@ fun FileSharePeerList(
false -> { false -> {
LazyColumn { LazyColumn {
peers.forEach { peer -> peers.forEach { peer ->
val disabled = !(peer.Online ?: false)
item { item {
PeerView( PeerView(
peer = peer, peer = peer,
onClick = { onShare(peer) }, onClick = { onShare(peer) },
disabled = disabled,
subtitle = { peer.Hostinfo.OS ?: "" }, subtitle = { peer.Hostinfo.OS ?: "" },
trailingContent = { stateViewGenerator(peer.StableID) }) trailingContent = { stateViewGenerator(peer.StableID) })
} }

@ -59,7 +59,7 @@ fun TailnetLockSetupView(
Icon( Icon(
painter = painterResource(id = statusItem.icon), painter = painterResource(id = statusItem.icon),
contentDescription = null, contentDescription = null,
tint = ts_color_light_blue) tint = MaterialTheme.colorScheme.onSurfaceVariant)
}, },
headlineContent = { Text(stringResource(statusItem.title)) }) headlineContent = { Text(stringResource(statusItem.title)) })
} }

@ -33,8 +33,8 @@ val logoDotsMatrix: DotsMatrix =
@Composable @Composable
fun TailscaleLogoView(animated: Boolean = false, modifier: Modifier) { fun TailscaleLogoView(animated: Boolean = false, modifier: Modifier) {
val primaryColor: Color = MaterialTheme.colorScheme.secondary val primaryColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
val secondaryColor: Color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) val secondaryColor: Color = primaryColor.copy(alpha = 0.1f)
val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix) val currentDotsMatrix: StateFlow<DotsMatrix> = MutableStateFlow(logoDotsMatrix)
var currentDotsMatrixIndex = 0 var currentDotsMatrixIndex = 0

@ -3,22 +3,10 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.tailscale.ipn.ui.theme.ts_color_light_blue
@Composable @Composable
fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) { fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) {
Switch( Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
colors =
SwitchDefaults.colors(
checkedBorderColor = ts_color_light_blue,
checkedThumbColor = ts_color_light_blue,
checkedTrackColor = ts_color_light_blue.copy(alpha = 0.3f),
uncheckedTrackColor = MaterialTheme.colorScheme.secondaryContainer))
} }

@ -5,6 +5,8 @@ package com.tailscale.ipn.ui.viewModel
import android.util.Log import android.util.Log
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@ -14,10 +16,9 @@ import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.ts_color_light_desctrutive_text import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.ts_color_light_green import com.tailscale.ipn.ui.theme.success
import com.tailscale.ipn.ui.util.FeatureStateRepresentation import com.tailscale.ipn.ui.util.FeatureStateRepresentation
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -41,10 +42,7 @@ class DNSSettingsViewModel() : IpnViewModel() {
R.string.use_ts_dns, R.string.use_ts_dns,
type = SettingType.SWITCH, type = SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS), isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = { onToggle = { toggleCorpDNS {} })
LoadingIndicator.start()
toggleCorpDNS { LoadingIndicator.stop() }
})
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -96,7 +94,7 @@ enum class DNSEnablementState : FeatureStateRepresentation {
get() = R.string.tailscale_is_not_running_this_device_is_using_the_system_dns_resolver get() = R.string.tailscale_is_not_running_this_device_is_using_the_system_dns_resolver
override val tint: Color override val tint: Color
get() = Color.Gray @Composable get() = MaterialTheme.colorScheme.off
override val symbolDrawable: Int override val symbolDrawable: Int
get() = R.drawable.xmark_circle get() = R.drawable.xmark_circle
@ -109,7 +107,7 @@ enum class DNSEnablementState : FeatureStateRepresentation {
@StringRes get() = R.string.this_device_is_using_tailscale_to_resolve_dns_names @StringRes get() = R.string.this_device_is_using_tailscale_to_resolve_dns_names
override val tint: Color override val tint: Color
get() = ts_color_light_green @Composable get() = MaterialTheme.colorScheme.success
override val symbolDrawable: Int override val symbolDrawable: Int
get() = R.drawable.check_circle get() = R.drawable.check_circle
@ -122,7 +120,7 @@ enum class DNSEnablementState : FeatureStateRepresentation {
@StringRes get() = R.string.this_device_is_using_the_system_dns_resolver @StringRes get() = R.string.this_device_is_using_the_system_dns_resolver
override val tint: Color override val tint: Color
get() = ts_color_light_desctrutive_text @Composable get() = MaterialTheme.colorScheme.error
override val symbolDrawable: Int override val symbolDrawable: Int
get() = R.drawable.xmark_circle get() = R.drawable.xmark_circle

@ -8,7 +8,6 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
@ -38,10 +37,7 @@ class MainViewModel : IpnViewModel() {
// The active search term for filtering peers // The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("") val searchTerm: StateFlow<String> = MutableStateFlow("")
// The peerID of the local node private val peerCategorizer = PeerCategorizer()
val selfPeerId: StateFlow<StableNodeID> = MutableStateFlow("")
private val peerCategorizer = PeerCategorizer(viewModelScope)
val userName: String val userName: String
get() { get() {
@ -57,16 +53,21 @@ class MainViewModel : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.collect { netmap -> Notifier.netmap.collect { it ->
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value)) it?.let { netmap ->
selfPeerId.set(netmap?.SelfNode?.StableID ?: "") peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
}
} }
} }
viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
}
} }
fun searchPeers(searchTerm: String) { fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm)) }
} }
fun disableExitNode() { fun disableExitNode() {

@ -3,18 +3,14 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.ComposableStringFormatter import com.tailscale.ipn.ui.util.ComposableStringFormatter
import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -31,45 +27,15 @@ class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val
} }
class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() { class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() {
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
// The peerID of the local node val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
val selfPeerId: StateFlow<StableNodeID> = MutableStateFlow("")
var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList()
val nodeName: String
val connectedStrRes: Int
val connectedColor: Color
init { init {
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.collect { netmap -> selfPeerId.set(netmap?.SelfNode?.StableID ?: "") } Notifier.netmap.collect { nm ->
netmap.set(nm)
nm?.getPeer(nodeId)?.let { peer -> node.set(peer) }
}
} }
val peer = Notifier.netmap.value?.getPeer(nodeId)
peer?.Addresses?.let { addresses = it.map { addr -> DisplayAddress(addr) } }
peer?.Name?.let { addresses = listOf(DisplayAddress(it)) + addresses }
peer?.let { p ->
info =
listOf(
PeerSettingInfo(R.string.os, ComposableStringFormatter(p.Hostinfo.OS ?: "")),
PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry)))
}
nodeName = peer?.ComputedName ?: ""
val stateVal = Notifier.state
val selfPeer = selfPeerId.value
val isSelfAndRunning =
(peer != null && peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
connectedStrRes =
if (peer?.Online == true || isSelfAndRunning) R.string.connected else R.string.not_connected
connectedColor =
if (peer?.Online == true || isSelfAndRunning) ts_color_light_green else Color.Gray
} }
} }

@ -88,7 +88,7 @@ class SettingsViewModel(val navigation: SettingsNav) : IpnViewModel() {
val about = val about =
Setting( Setting(
titleRes = R.string.about, titleRes = R.string.about_tailscale,
type = SettingType.NAV, type = SettingType.NAV,
onClick = { navigation.onNavigateToAbout() }, onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true)) enabled = MutableStateFlow(true))

@ -2,13 +2,13 @@
<resources> <resources>
<!-- Generic Strings --> <!-- Generic Strings -->
<string name="log_in">Log In</string> <string name="log_in">Log in</string>
<string name="log_out">Log Out</string> <string name="log_out">Log out</string>
<string name="none">None</string> <string name="none">None</string>
<string name="connect">Connect</string> <string name="connect">Connect</string>
<string name="unknown_user">Unknown User</string> <string name="unknown_user">Unknown user</string>
<string name="connected">Connected</string> <string name="connected">Connected</string>
<string name="not_connected">Not Connected</string> <string name="not_connected">Not connected</string>
<string name="empty"> </string> <string name="empty"> </string>
<string name="template">%s</string> <string name="template">%s</string>
<string name="more">More</string> <string name="more">More</string>
@ -20,16 +20,17 @@
<!-- Strings for the about screen --> <!-- Strings for the about screen -->
<string name="app_name">Tailscale</string> <string name="app_name">Tailscale</string>
<string name="tile_name">Tailscale</string> <string name="tile_name">Tailscale</string>
<string name="version">Version</string>
<string name="about_view_title">Tailscale for Android</string> <string name="about_view_title">Tailscale for Android</string>
<string name="acknowledgements">Acknowledgements</string> <string name="acknowledgements">Acknowledgements</string>
<string name="privacy_policy">Privacy Policy</string> <string name="privacy_policy">Privacy Policy</string>
<string name="terms_of_service">Terms of Service</string> <string name="terms_of_service">Terms of Service</string>
<string name="about_view_footnotes">WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2024 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc.</string> <string name="about_view_footnotes">WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2024 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc.</string>
<string name="app_icon_content_description">The Tailscale App Icon</string> <string name="app_icon_content_description">The Tailscale app icon</string>
<string name="managed_by">Managed By</string> <string name="managed_by">Managed by</string>
<!-- Strings for the bug reporting screen --> <!-- Strings for the bug reporting screen -->
<string name="bug_report_title">Report a Bug</string> <string name="bug_report_title">Report a bug</string>
<string name="bug_report_instructions_prefix">To report a bug,&#160;</string> <string name="bug_report_instructions_prefix">To report a bug,&#160;</string>
<string name="bug_report_instructions_linktext">contact our support team&#160;</string> <string name="bug_report_instructions_linktext">contact our support team&#160;</string>
<string name="bug_report_instructions_suffix"> and include the identifier below.</string> <string name="bug_report_instructions_suffix"> and include the identifier below.</string>
@ -38,37 +39,37 @@
<!-- Strings for the settings screen --> <!-- Strings for the settings screen -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_admin_prefix">You can manage your account from the admin console.&#160;</string> <string name="settings_admin_prefix">You can manage your account from the admin console.&#160;</string>
<string name="settings_admin_link">View admin console</string> <string name="settings_admin_link">View admin console</string>
<string name="about">About</string> <string name="about_tailscale">About Tailscale</string>
<string name="bug_report">Bug Report</string> <string name="bug_report">Bug report</string>
<string name="use_ts_dns">Use Tailscale DNS</string> <string name="use_ts_dns">Use Tailscale DNS</string>
<!-- Strings for the main screen --> <!-- Strings for the main screen -->
<string name="exit_node">EXIT NODE</string> <string name="exit_node">EXIT NODE</string>
<string name="starting">Starting…</string> <string name="starting">Starting…</string>
<string name="connect_to_tailnet">"Connect again to talk to the other devices in the %1$s tailnet."</string> <string name="connect_to_tailnet_prefix">"Connect again to talk to the other devices in the "</string>
<string name="connect_to_tailnet_suffix">" tailnet."</string>
<string name="welcome_to_tailscale">Welcome to Tailscale</string> <string name="welcome_to_tailscale">Welcome to Tailscale</string>
<string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string> <string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string>
<!-- Strings for peer details --> <!-- Strings for peer details -->
<string name="addresses_section"></string>
<string name="os">OS</string> <string name="os">OS</string>
<string name="key_expiry">Key Expiry</string> <string name="key_expiry">Key expiry</string>
<string name="peer_details">Tailscale Addresses</string> <string name="tailscale_addresses">Tailscale addresses</string>
<!-- Strings for MDM settings --> <!-- Strings for MDM settings -->
<string name="current_mdm_settings">Current MDM Settings</string> <string name="current_mdm_settings">Current MDM settings</string>
<string name="mdm_settings">MDM Settings</string> <string name="mdm_settings">MDM settings</string>
<string name="managed_by_orgName">Managed by %1$s</string> <string name="managed_by_orgName">Managed by %1$s</string>
<string name="managed_by_explainer">Your organization is managing Tailscale on this device. Some features might have been customized or hidden by your system administrator.</string> <string name="managed_by_explainer">Your organization is managing Tailscale on this device. Some features might have been customized or hidden by your system administrator.</string>
<string name="managed_by_explainer_orgName">%1$s is managing Tailscale on this device. Some features might have been customized or hidden by your system administrator.</string> <string name="managed_by_explainer_orgName">%1$s is managing Tailscale on this device. Some features might have been customized or hidden by your system administrator.</string>
<string name="open_support">Open Support</string> <string name="open_support">Open support</string>
<!-- State strings --> <!-- State strings -->
<string name="waiting">Loading…</string> <string name="waiting">Starting…</string>
<string name="placeholder">--</string> <string name="placeholder">--</string>
<string name="please_login">Login Required</string> <string name="please_login">Login required</string>
<string name="stopped">Stopped</string> <string name="stopped">Not connected</string>
<!-- Time conversion templates --> <!-- Time conversion templates -->
<string name="expired">expired</string> <string name="expired">expired</string>
@ -81,14 +82,14 @@
<!-- Strings for the user switcher --> <!-- Strings for the user switcher -->
<string name="user_switcher">Accounts</string> <string name="user_switcher">Accounts</string>
<string name="logout_failed">Unable to logout at this time. Please try again.</string> <string name="logout_failed">Unable to logout at this time. Please try again.</string>
<string name="error">Error</string> <string name="error">Error</string>
<string name="accounts">Accounts</string> <string name="accounts">Accounts</string>
<string name="add_account">Add Another Account</string> <string name="add_account">Add another account…</string>
<string name="add_account_short">Add Account</string> <string name="add_account_short">Add account</string>
<string name="reauthenticate">Reauthenticate</string> <string name="reauthenticate">Reauthenticate</string>
<string name="switch_user_failed">Unable to switch users. Please try again.</string> <string name="switch_user_failed">Unable to switch users. Please try again.</string>
<string name="add_profile_failed">Unable to add a new profile. Please try again.</string> <string name="add_profile_failed">Unable to add a new profile. Please try again.</string>
<string name="invalidCustomUrl">Please enter a valid URL in the form https://server.com</string> <string name="invalidCustomUrl">Please enter a valid URL in the form https://server.com</string>
<string name="invalidCustomURLTitle">Invalid URL</string> <string name="invalidCustomURLTitle">Invalid URL</string>
<string name="custom_control_menu">Add account using alternate server</string> <string name="custom_control_menu">Add account using alternate server</string>
@ -96,19 +97,19 @@
<string name="custom_control_placeholder">https://my.custom.server.com</string> <string name="custom_control_placeholder">https://my.custom.server.com</string>
<!-- Strings for ExitNode picker --> <!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string> <string name="choose_exit_node">Choose exit node</string>
<string name="choose_mullvad_exit_node">Mullvad Exit Nodes</string> <string name="choose_mullvad_exit_node">Mullvad exit nodes</string>
<string name="tailnet_exit_nodes">Tailnet Exit Nodes</string> <string name="tailnet_exit_nodes">Tailnet exit nodes</string>
<string name="mullvad_exit_nodes">Mullvad VPN</string> <string name="mullvad_exit_nodes">Mullvad VPN</string>
<string name="best_available">Best Available</string> <string name="best_available">Best available</string>
<string name="run_as_exit_node">Run as Exit Node</string> <string name="run_as_exit_node">Run as exit node</string>
<string name="run_this_device_as_an_exit_node">Run this device as an exit node?</string> <string name="run_this_device_as_an_exit_node">Run this device as an exit node?</string>
<string name="run_exit_node_explainer">Other devices in your tailnet will be able to route their Internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string> <string name="run_exit_node_explainer">Other devices in your tailnet will be able to route their internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string>
<string name="run_exit_node_caution">Caution: Running an exit node will severely impact battery life. On a metered data plan, significant cellular data charges may also apply. Always disable this feature when no longer needed.</string> <string name="run_exit_node_caution">Caution: Running an exit node will severely impact battery life. On a metered data plan, significant cellular data charges may also apply. Always disable this feature when no longer needed.</string>
<string name="stop_running_as_exit_node">Stop Running as Exit Node</string> <string name="stop_running_as_exit_node">Stop running as exit node</string>
<string name="start_running_as_exit_node">Start Running as Exit Node</string> <string name="start_running_as_exit_node">Start running as exit node</string>
<string name="running_as_exit_node">Now Running as Exit Node</string> <string name="running_as_exit_node">Now running as exit node</string>
<string name="run_exit_node_explainer_running">Other devices in your tailnet can now route their Internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string> <string name="run_exit_node_explainer_running">Other devices in your tailnet can now route their internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string>
<string name="enabled">Enabled</string> <string name="enabled">Enabled</string>
<string name="disabled">Disabled</string> <string name="disabled">Disabled</string>
<string name="disable">Disable</string> <string name="disable">Disable</string>
@ -122,38 +123,38 @@
<string name="this_node_has_not_been_signed">This node has not been signed by another device.</string> <string name="this_node_has_not_been_signed">This node has not been signed by another device.</string>
<string name="this_node_is_trusted">This node is trusted to change the Tailnet lock configuration.</string> <string name="this_node_is_trusted">This node is trusted to change the Tailnet lock configuration.</string>
<string name="this_node_is_not_trusted">This node is not trusted to change the Tailnet lock configuration.</string> <string name="this_node_is_not_trusted">This node is not trusted to change the Tailnet lock configuration.</string>
<string name="copy_to_clipboard">Copy to Clipboard</string> <string name="copy_to_clipboard">Copy to clipboard</string>
<string name="node_key">Node Key</string> <string name="node_key">Node key</string>
<string name="tailnet_lock_key">Tailnet Lock Key</string> <string name="tailnet_lock_key">Tailnet lock key</string>
<string name="node_key_explainer">Used to sign this node from another signing device in your tailnet.</string> <string name="node_key_explainer">Used to sign this node from another signing device in your tailnet.</string>
<string name="tailnet_lock_key_explainer">Used to authorize changes to the Tailnet lock configuration.</string> <string name="tailnet_lock_key_explainer">Used to authorize changes to the Tailnet lock configuration.</string>
<!-- Strings for the bug report screen --> <!-- Strings for the bug report screen -->
<string name="bug_report_id">Bug Report ID</string> <string name="bug_report_id">Bug report ID</string>
<string name="learn_more">Learn more</string> <string name="learn_more">Learn more</string>
<!-- Strings for DNS settings --> <!-- Strings for DNS settings -->
<string name="dns_settings">DNS Settings</string> <string name="dns_settings">DNS settings</string>
<string name="using_tailscale_dns">Using Tailscale DNS</string> <string name="using_tailscale_dns">Using Tailscale DNS</string>
<string name="this_device_is_using_tailscale_to_resolve_dns_names">This device is using Tailscale to resolve DNS names.</string> <string name="this_device_is_using_tailscale_to_resolve_dns_names">This device is using Tailscale to resolve DNS names.</string>
<string name="resolvers">Resolvers</string> <string name="resolvers">Resolvers</string>
<string name="search_domains">Search Domains</string> <string name="search_domains">Search somains</string>
<string name="not_running">Not Running</string> <string name="not_running">Not running</string>
<string name="tailscale_is_not_running_this_device_is_using_the_system_dns_resolver">Tailscale is not running. This device is using the system\'s DNS resolver.</string> <string name="tailscale_is_not_running_this_device_is_using_the_system_dns_resolver">Tailscale is not running. This device is using the system\'s DNS resolver.</string>
<string name="this_device_is_using_the_system_dns_resolver">This device is using the system DNS resolver.</string> <string name="this_device_is_using_the_system_dns_resolver">This device is using the system DNS resolver.</string>
<string name="not_using_tailscale_dns">Not Using Tailscale DNS</string> <string name="not_using_tailscale_dns">Not using Tailscale DNS</string>
<string name="allow_lan_access">Allow LAN Access</string> <string name="allow_lan_access">Allow LAN access</string>
<string name="exit_nodes_available">exit nodes available</string> <string name="exit_nodes_available">exit nodes available</string>
<string name="cities_available">cities available</string> <string name="cities_available">cities available</string>
<!-- Strings for MDM Settings Manifest (app_restrictions.xml) --> <!-- Strings for MDM Settings Manifest (app_restrictions.xml) -->
<string name="prevents_the_user_from_disconnecting_tailscale">Prevents the user from disconnecting Tailscale.</string> <string name="prevents_the_user_from_disconnecting_tailscale">Prevents the user from disconnecting Tailscale.</string>
<string name="force_enabled_connection_toggle">Force Enabled Connection Toggle</string> <string name="force_enabled_connection_toggle">Force enabled connection toggle</string>
<string name="exit_node_id">Exit Node ID</string> <string name="exit_node_id">Exit node ID</string>
<string name="forces_the_tailscale_client_to_always_use_the_exit_node_with_the_given_id">Forces the Tailscale client to always use the exit node with the given ID.</string> <string name="forces_the_tailscale_client_to_always_use_the_exit_node_with_the_given_id">Forces the Tailscale client to always use the exit node with the given ID.</string>
<string name="managed_by_organization_name">Managed By - Organization Name</string> <string name="managed_by_organization_name">Managed by - organization name</string>
<string name="managed_by_caption">Managed By - Caption</string> <string name="managed_by_caption">Managed by - caption</string>
<string name="managed_by_url">Managed By - URL</string> <string name="managed_by_url">Managed by - URL</string>
<string name="shows_a_button_to_open_support_resources_next_to_the_organization_name">Shows a button to open support resources next to the organization name.</string> <string name="shows_a_button_to_open_support_resources_next_to_the_organization_name">Shows a button to open support resources next to the organization name.</string>
<string name="shows_the_given_caption_next_to_the_organization_name_in_the_client">Shows the given caption next to the organization name in the client.</string> <string name="shows_the_given_caption_next_to_the_organization_name_in_the_client">Shows the given caption next to the organization name in the client.</string>
<string name="shows_the_given_organization_name_in_the_client">Shows the given organization name in the client.</string> <string name="shows_the_given_organization_name_in_the_client">Shows the given organization name in the client.</string>
@ -161,38 +162,36 @@
<string name="required_suggested_tailnet">Required/Suggested Tailnet</string> <string name="required_suggested_tailnet">Required/Suggested Tailnet</string>
<string name="custom_control_server_url">Custom control server URL</string> <string name="custom_control_server_url">Custom control server URL</string>
<string name="use_this_field_to_specify_a_custom_coordination_server_url_such_as_a_headscale_instance">Use this field to specify a custom coordination server URL, such as a Headscale instance.</string> <string name="use_this_field_to_specify_a_custom_coordination_server_url_such_as_a_headscale_instance">Use this field to specify a custom coordination server URL, such as a Headscale instance.</string>
<string name="hidden_network_devices">Hidden Network Devices</string> <string name="hidden_network_devices">Hidden network devices</string>
<string name="hides_the_specified_categories_of_network_devices_from_the_devices_list_in_the_client">Hides the specified categories of network devices from the devices list in the client.</string> <string name="hides_the_specified_categories_of_network_devices_from_the_devices_list_in_the_client">Hides the specified categories of network devices from the devices list in the client.</string>
<string name="allow_lan_access_when_using_an_exit_node">Allow LAN Access when using an exit node</string> <string name="allow_lan_access_when_using_an_exit_node">Allow LAN access when using an exit node</string>
<string name="enable_posture_checking">Enable Posture Checking</string> <string name="enable_posture_checking">Enable posture checking</string>
<string name="use_tailscale_dns_settings">Use Tailscale DNS Settings</string> <string name="use_tailscale_dns_settings">Use Tailscale DNS settings</string>
<string name="use_tailscale_subnets">Use Tailscale Subnets</string> <string name="use_tailscale_subnets">Use Tailscale subnets</string>
<string name="allow_incoming_connections">Allow Incoming Connections</string> <string name="allow_incoming_connections">Allow incoming connections</string>
<string name="exit_node_picker_visibility">Exit Node Picker Visibility</string> <string name="exit_node_picker_visibility">Exit node picker visibility</string>
<string name="shows_or_hides_the_exit_node_picker_in_the_main_view_of_the_app">Shows or hides the exit node picker in the main view of the app.</string> <string name="shows_or_hides_the_exit_node_picker_in_the_main_view_of_the_app">Shows or hides the exit node picker in the main view of the app.</string>
<string name="shows_or_hides_the_tailnet_lock_configuration_ui">Shows or hides the Tailnet lock configuration UI.</string> <string name="shows_or_hides_the_tailnet_lock_configuration_ui">Shows or hides the Tailnet lock configuration UI.</string>
<string name="manage_tailnet_lock_visibility">Manage Tailnet lock visibility</string> <string name="manage_tailnet_lock_visibility">Manage Tailnet lock visibility</string>
<string name="shows_or_hides_the_ui_to_run_the_android_device_as_an_exit_node">Shows or hides the UI to run the Android device as an exit node.</string> <string name="shows_or_hides_the_ui_to_run_the_android_device_as_an_exit_node">Shows or hides the UI to run the Android device as an exit node.</string>
<string name="run_as_exit_node_visibility">Run As Exit Node visibility</string> <string name="run_as_exit_node_visibility">Run as exit node visibility</string>
<!-- Permissions Management --> <!-- Permissions Management -->
<string name="permissions">Permissions</string> <string name="permissions">Permissions</string>
<string name="permission_required">Permission Required</string> <string name="permission_required">Permission required</string>
<string name="permission_write_external_storage">Storage</string> <string name="permission_write_external_storage">Storage</string>
<string name="permission_write_external_storage_needed">Please grant Tailscale the Storage permission in order to receive files with Taildrop.</string> <string name="permission_write_external_storage_needed">We use storage in order to receive files with Taildrop.</string>
<string name="permission_write_external_storage_granted">Thank you for granting Tailscale the Storage permission.</string>
<string name="permission_post_notifications">Notifications</string> <string name="permission_post_notifications">Notifications</string>
<string name="permission_post_notifications_needed">Please grant Tailscale the Notifications permission in order to receive important status notifications.</string> <string name="permission_post_notifications_needed">We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network.</string>
<string name="permission_post_notifications_granted">Thank you for granting Tailscale the Notifications permission.</string>
<!-- Strings for the share activity --> <!-- Strings for the share activity -->
<string name="share">Send via Tailscale</string> <string name="share">Send via Tailscale</string>
<string name="share_device_not_connected">Unable to share files with this device. This device is not currently connected to the tailnet.</string> <string name="share_device_not_connected">Unable to share files with this device. This device is not currently connected to the tailnet.</string>
<string name="no_files_to_share">No files to share</string> <string name="no_files_to_share">No files to share</string>
<string name="file_count">%1$s Files</string> <string name="file_count">%1$s files</string>
<string name="connect_to_your_tailnet_to_share_files">Connect to your tailnet to share files</string> <string name="connect_to_your_tailnet_to_share_files">Connect to your tailnet to share files</string>
<string name="my_devices">My Devices</string> <string name="my_devices">My devices</string>
<string name="no_devices_to_share_with">There are no devices on your tailnet to share to</string> <string name="no_devices_to_share_with">There are no devices on your tailnet to share to</string>
<string name="taildrop_share_failed">Taildrop failed. Some files were not shared. Please try again.</string> <string name="taildrop_share_failed">Taildrop failed. Some files were not shared. Please try again.</string>
<string name="taildrop_sending">Sending</string> <string name="taildrop_sending">Sending</string>
@ -200,15 +199,15 @@
<!-- Error Dialog Titles --> <!-- Error Dialog Titles -->
<string name="logout_failed_title">Logout Failed</string> <string name="logout_failed_title">Logout failed</string>
<string name="switch_user_failed_title">Cannot Switch To User</string> <string name="switch_user_failed_title">Cannot switch to user</string>
<string name="add_profile_failed_title">Unable to Add Profile</string> <string name="add_profile_failed_title">Unable to add profile</string>
<string name="share_device_not_connected_title">Not Connected</string> <string name="share_device_not_connected_title">Not connected</string>
<string name="taildrop_share_failed_title">Taildrop Failed</string> <string name="taildrop_share_failed_title">Taildrop failed</string>
<!-- Strings for the intro screen --> <!-- Strings for the intro screen -->
<string name="tailscale">Tailscale</string> <string name="tailscale">Tailscale</string>
<string name="getStarted">Get Started</string> <string name="getStarted">Get started</string>
<string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.\n\nWe collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.\n</string> <string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.\n\nWe collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.\n</string>

Loading…
Cancel
Save