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 {
ext.kotlin_version = "1.9.22"
ext.kotlin_compose_version = "1.5.10"
ext.accompanist_version = "0.34.0"
repositories {
google()
@ -88,7 +89,8 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
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.
def nav_version = "2.7.7"

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

@ -3,7 +3,17 @@
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.json.JsonElement
class Tailcfg {
@Serializable
@ -70,11 +80,14 @@ class Tailcfg {
var LastSeen: Time? = null,
var Online: Boolean? = null,
var Capabilities: List<String>? = null,
var CapMap: Map<String, JsonElement?>? = null,
var ComputedName: String,
var ComputedNameWithHost: String
) {
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
val isExitNode: Boolean =
@ -82,6 +95,41 @@ class Tailcfg {
val isMullvadNode: Boolean
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

@ -5,19 +5,5 @@ package com.tailscale.ipn.ui.theme
import androidx.compose.ui.graphics.Color
val ts_color_light_primary = Color(0xFF232222)
val ts_color_light_secondary = Color(0xFF706E6D)
val ts_color_light_background = Color(0xFFFFFFFF)
val ts_color_light_tintedBackground = Color(0xFFF7F5F4)
// TODO: replace references to these with references to material theme
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
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.darkColorScheme
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme
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 =
lightColorScheme(
primary = ts_color_light_primary,
onPrimary = ts_color_light_background,
secondary = ts_color_light_secondary,
onSecondary = ts_color_light_background,
secondaryContainer = ts_color_light_tintedBackground,
surface = ts_color_light_background,
primary = Color(0xFF4B70CC), // blue-500
onPrimary = Color(0xFFFFFFFF), // white
primaryContainer = Color(0xFFF0F5FF), // blue-0
// primaryContainer = Color(0xFF6D94EC), // blue-400
onPrimaryContainer = Color(0xFF3E5DB3), // blue-600
// 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 =
darkColorScheme(
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,
)
val ColorScheme.warning: Color
get() = Color(0xFFD97916) // yellow-300
@Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors =
if (!useDarkTheme) {
LightColors
} else {
DarkColors
}
MaterialTheme(colorScheme = colors, content = content)
}
val ColorScheme.onWarning: Color
get() = Color(0xFFFFFFFF) // white
val ColorScheme.warningContainer: Color
get() = Color(0xFFFFFAEE) // orange-0
val ColorScheme.onWarningContainer: Color
get() = Color(0xFF7E1E22) // orange-600
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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.ts_color_light_blue
import com.tailscale.ipn.ui.theme.listItem
@Composable
fun ClipboardValueView(
@ -32,33 +31,30 @@ fun ClipboardValueView(
fontFamily: FontFamily = FontFamily.Monospace
) {
val localClipboardManager = LocalClipboardManager.current
Row {
ListItem(
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) },
overlineContent = { title?.let { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = {
ListItem(
colors = MaterialTheme.colorScheme.listItem,
modifier = Modifier.clickable { localClipboardManager.setText(AnnotatedString(value)) },
overlineContent = { title?.let { Text(it, style = MaterialTheme.typography.titleMedium) } },
headlineContent = {
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontFamily = fontFamily,
maxLines = 2,
overflow = TextOverflow.Ellipsis)
},
supportingContent = {
subtitle?.let { subtitle ->
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontFamily = fontFamily,
maxLines = 2,
overflow = TextOverflow.Ellipsis)
},
supportingContent = {
subtitle?.let { subtitle ->
Text(
subtitle,
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)
})
}
subtitle,
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodyMedium)
}
},
trailingContent = {
Icon(
painterResource(R.drawable.clipboard),
stringResource(R.string.copy_to_clipboard),
modifier = Modifier.width(24.dp).height(24.dp))
})
}

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

@ -26,7 +26,7 @@ object Lists {
@Composable
fun ItemDivider() {
HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
}
@ -35,7 +35,6 @@ inline fun <T> LazyListScope.itemsWithDividers(
items: List<T>,
noinline key: ((item: T) -> Any)? = null,
forceLeading: Boolean = false,
forceTrailing: Boolean = false,
crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) =
@ -43,9 +42,7 @@ inline fun <T> LazyListScope.itemsWithDividers(
count = items.size,
key = if (key != null) { index: Int -> key(items[index]) } else null,
contentType = { index -> contentType(items[index]) }) {
if (forceLeading && it == 0 ||
forceTrailing && it == items.size ||
it > 0 && it < items.size) {
if (forceLeading && it == 0 || it > 0 && it < items.size) {
Lists.ItemDivider()
}
itemContent(items[it])
@ -55,7 +52,6 @@ inline fun <T> LazyListScope.itemsWithDividers(
items: Array<T>,
noinline key: ((item: T) -> Any)? = null,
forceLeading: Boolean = false,
forceTrailing: Boolean = false,
crossinline contentType: (item: T) -> Any? = { _ -> null },
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.Tailcfg
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>)
typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>>
class PeerCategorizer(scope: CoroutineScope) {
class PeerCategorizer {
var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList()
var searchTerm: String = ""
var lastSearchTerm: String = ""
// Keep the peer sets current while the model is active
init {
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()
fun regenerateGroupedPeers(netmap: Netmap.NetworkMap) {
val peers: List<Tailcfg.Node> = netmap.Peers ?: return
val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
@ -56,7 +35,7 @@ class PeerCategorizer(scope: CoroutineScope) {
val me = netmap.currentUserProfile()
val peerSets =
peerSets =
grouped
.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
@ -66,11 +45,9 @@ class PeerCategorizer(scope: CoroutineScope) {
if (it.user?.ID == me?.ID) {
""
} else {
it.user?.DisplayName ?: "Unknown User"
it.user?.DisplayName?.lowercase() ?: "unknown user"
}
}
return peerSets
}
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
@ -78,14 +55,17 @@ class PeerCategorizer(scope: CoroutineScope) {
return peerSets
}
if (searchTerm == this.searchTerm) {
if (searchTerm == this.lastSearchTerm) {
return lastSearchResult
}
// We can optimize out typing... If the search term starts with the last search term, we can
// just search the last result
val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
this.searchTerm = searchTerm
val setsToSearch =
if (this.lastSearchTerm.isNotEmpty() && searchTerm.startsWith(this.lastSearchTerm))
lastSearchResult
else peerSets
this.lastSearchTerm = searchTerm
val matchingSets =
setsToSearch
@ -107,7 +87,7 @@ class PeerCategorizer(scope: CoroutineScope) {
}
}
.filterNotNull()
lastSearchResult = matchingSets
return matchingSets
}
}

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

@ -47,6 +47,7 @@ fun AboutView(nav: BackNavigation) {
.padding(15.dp),
painter = painterResource(id = R.drawable.ic_tile),
contentDescription = stringResource(R.string.app_icon_content_description))
Column(
verticalArrangement =
Arrangement.spacedBy(space = 2.dp, alignment = Alignment.CenterVertically),
@ -54,28 +55,23 @@ fun AboutView(nav: BackNavigation) {
Text(
stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
color = MaterialTheme.colorScheme.primary)
fontSize = MaterialTheme.typography.titleLarge.fontSize)
Text(
text = BuildConfig.VERSION_NAME,
text = "${stringResource(R.string.version)} ${BuildConfig.VERSION_NAME}",
fontWeight = MaterialTheme.typography.bodyMedium.fontWeight,
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)
fontSize = MaterialTheme.typography.bodyMedium.fontSize)
}
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(
stringResource(R.string.about_view_footnotes),
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center)
}
}

@ -3,15 +3,17 @@
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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -22,22 +24,20 @@ import com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class)
@Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
Box(
contentAlignment = Alignment.Center,
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50, action: (() -> Unit)? = null) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size.dp).clip(CircleShape)) {
var modifier = Modifier.size((size * .8f).dp)
action?.let {
modifier =
Modifier.size(size.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp))
modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false),
onClick = action)
}
Icon(imageVector = Icons.Default.Person, contentDescription = null, modifier = modifier)
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(
model = url, modifier = Modifier.size((size * 1.2f).dp), contentDescription = null)
}
}
profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage(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 com.tailscale.ipn.R
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.Lists
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@Composable
@ -40,25 +39,16 @@ fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel())
Column(modifier = Modifier.padding(innerPadding).fillMaxWidth().fillMaxHeight()) {
ListItem(
headlineContent = {
ClickableText(
text = contactText(),
modifier = Modifier.fillMaxWidth(),
onClick = { handler.openUri(Links.SUPPORT_URL) })
ClickableText(text = contactText(), onClick = { handler.openUri(Links.SUPPORT_URL) })
})
Lists.SectionDivider()
ClipboardValueView(bugReportID, title = stringResource(R.string.bug_report_id))
Lists.SectionDivider()
ListItem(
headlineContent = {
Text(
text = stringResource(id = R.string.bug_report_id_desc),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall)
})
}
@ -68,20 +58,19 @@ fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel())
@Composable
fun contactText(): AnnotatedString {
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)
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))
}
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
}

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

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

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

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

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

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

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

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

@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
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.Warning
import androidx.compose.material3.Icon
@ -30,7 +29,8 @@ import com.tailscale.ipn.ui.util.itemsWithDividers
@OptIn(ExperimentalPermissionsApi::class)
@Composable
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 permissionStates =
rememberMultiplePermissionsState(permissions = permissions.map { it.name })
@ -54,20 +54,7 @@ fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) {
headlineContent = {
Text(stringResource(permission.title), style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
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))
}
},
supportingContent = { Text(stringResource(permission.description)) },
)
}
}

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

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

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

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

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

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

@ -3,22 +3,10 @@
package com.tailscale.ipn.ui.view
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.Composable
import com.tailscale.ipn.ui.theme.ts_color_light_blue
@Composable
fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) {
Switch(
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))
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
}

@ -5,6 +5,8 @@ package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
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.Tailcfg
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.ts_color_light_desctrutive_text
import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.theme.success
import com.tailscale.ipn.ui.util.FeatureStateRepresentation
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -41,10 +42,7 @@ class DNSSettingsViewModel() : IpnViewModel() {
R.string.use_ts_dns,
type = SettingType.SWITCH,
isOn = MutableStateFlow(Notifier.prefs.value?.CorpDNS),
onToggle = {
LoadingIndicator.start()
toggleCorpDNS { LoadingIndicator.stop() }
})
onToggle = { toggleCorpDNS {} })
init {
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
override val tint: Color
get() = Color.Gray
@Composable get() = MaterialTheme.colorScheme.off
override val symbolDrawable: Int
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
override val tint: Color
get() = ts_color_light_green
@Composable get() = MaterialTheme.colorScheme.success
override val symbolDrawable: Int
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
override val tint: Color
get() = ts_color_light_desctrutive_text
@Composable get() = MaterialTheme.colorScheme.error
override val symbolDrawable: Int
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.model.Ipn
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.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerCategorizer
@ -38,10 +37,7 @@ class MainViewModel : IpnViewModel() {
// The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("")
// The peerID of the local node
val selfPeerId: StateFlow<StableNodeID> = MutableStateFlow("")
private val peerCategorizer = PeerCategorizer(viewModelScope)
private val peerCategorizer = PeerCategorizer()
val userName: String
get() {
@ -57,16 +53,21 @@ class MainViewModel : IpnViewModel() {
}
viewModelScope.launch {
Notifier.netmap.collect { netmap ->
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
selfPeerId.set(netmap?.SelfNode?.StableID ?: "")
Notifier.netmap.collect { it ->
it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
}
}
}
viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
}
}
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm)) }
}
fun disableExitNode() {

@ -3,18 +3,14 @@
package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Netmap
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.theme.ts_color_light_green
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 kotlinx.coroutines.flow.MutableStateFlow
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() {
// The peerID of the local node
val selfPeerId: StateFlow<StableNodeID> = MutableStateFlow("")
var addresses: List<DisplayAddress> = emptyList()
var info: List<PeerSettingInfo> = emptyList()
val nodeName: String
val connectedStrRes: Int
val connectedColor: Color
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
init {
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 =
Setting(
titleRes = R.string.about,
titleRes = R.string.about_tailscale,
type = SettingType.NAV,
onClick = { navigation.onNavigateToAbout() },
enabled = MutableStateFlow(true))

@ -2,13 +2,13 @@
<resources>
<!-- Generic Strings -->
<string name="log_in">Log In</string>
<string name="log_out">Log Out</string>
<string name="log_in">Log in</string>
<string name="log_out">Log out</string>
<string name="none">None</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="not_connected">Not Connected</string>
<string name="not_connected">Not connected</string>
<string name="empty"> </string>
<string name="template">%s</string>
<string name="more">More</string>
@ -20,16 +20,17 @@
<!-- Strings for the about screen -->
<string name="app_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="acknowledgements">Acknowledgements</string>
<string name="privacy_policy">Privacy Policy</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="app_icon_content_description">The Tailscale App Icon</string>
<string name="managed_by">Managed By</string>
<string name="app_icon_content_description">The Tailscale app icon</string>
<string name="managed_by">Managed by</string>
<!-- 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_linktext">contact our support team&#160;</string>
<string name="bug_report_instructions_suffix"> and include the identifier below.</string>
@ -38,37 +39,37 @@
<!-- Strings for the settings screen -->
<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_link">View admin console</string>
<string name="about">About</string>
<string name="bug_report">Bug Report</string>
<string name="settings_admin_link">View admin console</string>
<string name="about_tailscale">About Tailscale</string>
<string name="bug_report">Bug report</string>
<string name="use_ts_dns">Use Tailscale DNS</string>
<!-- Strings for the main screen -->
<string name="exit_node">EXIT NODE</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="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string>
<!-- Strings for peer details -->
<string name="addresses_section"></string>
<string name="os">OS</string>
<string name="key_expiry">Key Expiry</string>
<string name="peer_details">Tailscale Addresses</string>
<string name="key_expiry">Key expiry</string>
<string name="tailscale_addresses">Tailscale addresses</string>
<!-- Strings for MDM settings -->
<string name="current_mdm_settings">Current MDM Settings</string>
<string name="mdm_settings">MDM Settings</string>
<string name="current_mdm_settings">Current MDM settings</string>
<string name="mdm_settings">MDM settings</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_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 -->
<string name="waiting">Loading…</string>
<string name="waiting">Starting…</string>
<string name="placeholder">--</string>
<string name="please_login">Login Required</string>
<string name="stopped">Stopped</string>
<string name="please_login">Login required</string>
<string name="stopped">Not connected</string>
<!-- Time conversion templates -->
<string name="expired">expired</string>
@ -81,14 +82,14 @@
<!-- Strings for the user switcher -->
<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="accounts">Accounts</string>
<string name="add_account">Add Another Account</string>
<string name="add_account_short">Add Account</string>
<string name="add_account">Add another account…</string>
<string name="add_account_short">Add account</string>
<string name="reauthenticate">Reauthenticate</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="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="invalidCustomUrl">Please enter a valid URL in the form https://server.com</string>
<string name="invalidCustomURLTitle">Invalid URL</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>
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string>
<string name="choose_mullvad_exit_node">Mullvad Exit Nodes</string>
<string name="tailnet_exit_nodes">Tailnet Exit Nodes</string>
<string name="choose_exit_node">Choose exit node</string>
<string name="choose_mullvad_exit_node">Mullvad exit nodes</string>
<string name="tailnet_exit_nodes">Tailnet exit nodes</string>
<string name="mullvad_exit_nodes">Mullvad VPN</string>
<string name="best_available">Best Available</string>
<string name="run_as_exit_node">Run as Exit Node</string>
<string name="best_available">Best available</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_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="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="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="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="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="enabled">Enabled</string>
<string name="disabled">Disabled</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_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="copy_to_clipboard">Copy to Clipboard</string>
<string name="node_key">Node Key</string>
<string name="tailnet_lock_key">Tailnet Lock Key</string>
<string name="copy_to_clipboard">Copy to clipboard</string>
<string name="node_key">Node 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="tailnet_lock_key_explainer">Used to authorize changes to the Tailnet lock configuration.</string>
<!-- Strings for the bug report screen -->
<string name="bug_report_id">Bug Report ID</string>
<string name="learn_more">Learn more</string>
<string name="bug_report_id">Bug report ID</string>
<string name="learn_more">Learn more</string>
<!-- 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="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="search_domains">Search Domains</string>
<string name="not_running">Not Running</string>
<string name="search_domains">Search somains</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="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="allow_lan_access">Allow LAN Access</string>
<string name="not_using_tailscale_dns">Not using Tailscale DNS</string>
<string name="allow_lan_access">Allow LAN access</string>
<string name="exit_nodes_available">exit nodes available</string>
<string name="cities_available">cities available</string>
<!-- 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="force_enabled_connection_toggle">Force Enabled Connection Toggle</string>
<string name="exit_node_id">Exit Node ID</string>
<string name="force_enabled_connection_toggle">Force enabled connection toggle</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="managed_by_organization_name">Managed By - Organization Name</string>
<string name="managed_by_caption">Managed By - Caption</string>
<string name="managed_by_url">Managed By - URL</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_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_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>
@ -161,38 +162,36 @@
<string name="required_suggested_tailnet">Required/Suggested Tailnet</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="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="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="use_tailscale_dns_settings">Use Tailscale DNS Settings</string>
<string name="use_tailscale_subnets">Use Tailscale Subnets</string>
<string name="allow_incoming_connections">Allow Incoming Connections</string>
<string name="exit_node_picker_visibility">Exit Node Picker Visibility</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="use_tailscale_dns_settings">Use Tailscale DNS settings</string>
<string name="use_tailscale_subnets">Use Tailscale subnets</string>
<string name="allow_incoming_connections">Allow incoming connections</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_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="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 -->
<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_needed">Please grant Tailscale the Storage permission 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_write_external_storage_needed">We use storage in order to receive files with Taildrop.</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_granted">Thank you for granting Tailscale the Notifications permission.</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>
<!-- Strings for the share activity -->
<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="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="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="taildrop_share_failed">Taildrop failed. Some files were not shared. Please try again.</string>
<string name="taildrop_sending">Sending</string>
@ -200,15 +199,15 @@
<!-- Error Dialog Titles -->
<string name="logout_failed_title">Logout Failed</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="share_device_not_connected_title">Not Connected</string>
<string name="taildrop_share_failed_title">Taildrop Failed</string>
<string name="logout_failed_title">Logout failed</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="share_device_not_connected_title">Not connected</string>
<string name="taildrop_share_failed_title">Taildrop failed</string>
<!-- Strings for the intro screen -->
<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>

Loading…
Cancel
Save