android: add Tailnet lock setup UI (#231)
Updates ENG-2981 Adds a view to see the Tailnet lock settings and copy node key and public key, resembling the iOS and macOS ones. Signed-off-by: Andrea Gottardo <andrea@gottardo.me>pull/233/head
parent
19adff3077
commit
f96e9b923f
@ -0,0 +1,82 @@
|
|||||||
|
// 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.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ClipboardValueView(
|
||||||
|
value: String,
|
||||||
|
title: String? = null,
|
||||||
|
subtitle: String? = null,
|
||||||
|
fontFamily: FontFamily = FontFamily.Monospace
|
||||||
|
) {
|
||||||
|
val localClipboardManager = LocalClipboardManager.current
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
modifier = Modifier.clip(shape = RoundedCornerShape(8.dp))) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement =
|
||||||
|
Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(value)) }),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().weight(10f)) {
|
||||||
|
title?.let { title ->
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis)
|
||||||
|
subtitle?.let { subtitle ->
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.clipboard),
|
||||||
|
stringResource(R.string.copy_to_clipboard),
|
||||||
|
modifier = Modifier.width(24.dp).height(24.dp),
|
||||||
|
tint = ts_color_light_blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,123 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
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.R
|
||||||
|
import com.tailscale.ipn.ui.Links
|
||||||
|
import com.tailscale.ipn.ui.theme.ts_color_light_blue
|
||||||
|
import com.tailscale.ipn.ui.util.ClipboardValueView
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TailnetLockSetupView(
|
||||||
|
nav: BackNavigation,
|
||||||
|
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
|
||||||
|
) {
|
||||||
|
val statusItems = model.statusItems.collectAsState().value
|
||||||
|
val nodeKey = model.nodeKey.collectAsState().value
|
||||||
|
val tailnetLockKey = model.tailnetLockKey.collectAsState().value
|
||||||
|
|
||||||
|
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = nav.onBack) }) { innerPadding ->
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.padding(innerPadding)) {
|
||||||
|
item(key = "header") {
|
||||||
|
ExplainerView()
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
items(items = statusItems) { statusItem ->
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = statusItem.icon),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ts_color_light_blue)
|
||||||
|
Text(stringResource(statusItem.title))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "nodeKey") {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
ClipboardValueView(
|
||||||
|
value = nodeKey,
|
||||||
|
title = stringResource(R.string.node_key),
|
||||||
|
subtitle = stringResource(R.string.node_key_explainer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "tailnetLockKey") {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
ClipboardValueView(
|
||||||
|
value = tailnetLockKey,
|
||||||
|
title = stringResource(R.string.tailnet_lock_key),
|
||||||
|
subtitle = stringResource(R.string.tailnet_lock_key_explainer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ExplainerView() {
|
||||||
|
val handler = LocalUriHandler.current
|
||||||
|
|
||||||
|
ClickableText(
|
||||||
|
explainerText(),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
onClick = { handler.openUri(Links.TAILNET_LOCK_KB_URL) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun explainerText(): AnnotatedString {
|
||||||
|
val annotatedString = buildAnnotatedString {
|
||||||
|
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
|
||||||
|
append(stringResource(id = R.string.tailnet_lock_explainer))
|
||||||
|
}
|
||||||
|
|
||||||
|
pushStringAnnotation(tag = "tailnetLockSupportURL", annotation = Links.TAILNET_LOCK_KB_URL)
|
||||||
|
withStyle(
|
||||||
|
style = SpanStyle(color = ts_color_light_blue, textDecoration = TextDecoration.Underline)) {
|
||||||
|
append(stringResource(id = R.string.learn_more))
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
return annotatedString
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.viewModel
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.localapi.Client
|
||||||
|
import com.tailscale.ipn.ui.model.IpnState
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.set
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class TailnetLockSetupViewModelFactory() : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return TailnetLockSetupViewModel() as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StatusItem(@StringRes val title: Int, @DrawableRes val icon: Int)
|
||||||
|
|
||||||
|
class TailnetLockSetupViewModel() : IpnViewModel() {
|
||||||
|
|
||||||
|
val statusItems: StateFlow<List<StatusItem>> = MutableStateFlow(emptyList())
|
||||||
|
val nodeKey: StateFlow<String> = MutableStateFlow("unknown")
|
||||||
|
val tailnetLockKey: StateFlow<String> = MutableStateFlow("unknown")
|
||||||
|
|
||||||
|
init {
|
||||||
|
LoadingIndicator.start()
|
||||||
|
Client(viewModelScope).tailnetLockStatus { result ->
|
||||||
|
statusItems.set(generateStatusItems(result.getOrNull()))
|
||||||
|
nodeKey.set(result.getOrNull()?.NodeKey ?: "unknown")
|
||||||
|
tailnetLockKey.set(result.getOrNull()?.PublicKey ?: "unknown")
|
||||||
|
LoadingIndicator.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateStatusItems(networkLockStatus: IpnState.NetworkLockStatus?): List<StatusItem> {
|
||||||
|
networkLockStatus?.let { status ->
|
||||||
|
val items = emptyList<StatusItem>().toMutableList()
|
||||||
|
if (status.Enabled == true) {
|
||||||
|
items.add(StatusItem(title = R.string.tailnet_lock_enabled, icon = R.drawable.check_circle))
|
||||||
|
} else {
|
||||||
|
items.add(
|
||||||
|
StatusItem(title = R.string.tailnet_lock_disabled, icon = R.drawable.xmark_circle))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.NodeKeySigned == true) {
|
||||||
|
items.add(
|
||||||
|
StatusItem(title = R.string.this_node_has_been_signed, icon = R.drawable.check_circle))
|
||||||
|
} else {
|
||||||
|
items.add(
|
||||||
|
StatusItem(
|
||||||
|
title = R.string.this_node_has_not_been_signed, icon = R.drawable.xmark_circle))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.IsPublicKeyTrusted()) {
|
||||||
|
items.add(StatusItem(title = R.string.this_node_is_trusted, icon = R.drawable.check_circle))
|
||||||
|
} else {
|
||||||
|
items.add(
|
||||||
|
StatusItem(title = R.string.this_node_is_not_trusted, icon = R.drawable.xmark_circle))
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
?: run {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M424,552L338,466Q327,455 310,455Q293,455 282,466Q271,477 271,494Q271,511 282,522L396,636Q408,648 424,648Q440,648 452,636L678,410Q689,399 689,382Q689,365 678,354Q667,343 650,343Q633,343 622,354L424,552ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||||
|
|
||||||
|
</vector>
|
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,280Q120,263 131.5,251.5Q143,240 160,240Q177,240 188.5,251.5Q200,263 200,280L200,800Q200,800 200,800Q200,800 200,800L600,800Q617,800 628.5,811.5Q640,823 640,840Q640,857 628.5,868.5Q617,880 600,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640L360,640Z"/>
|
||||||
|
|
||||||
|
</vector>
|
@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
|
||||||
|
|
||||||
|
<path android:fillColor="@android:color/white" android:pathData="M480,536L596,652Q607,663 624,663Q641,663 652,652Q663,641 663,624Q663,607 652,596L536,480L652,364Q663,353 663,336Q663,319 652,308Q641,297 624,297Q607,297 596,308L480,424L364,308Q353,297 336,297Q319,297 308,308Q297,319 297,336Q297,353 308,364L424,480L308,596Q297,607 297,624Q297,641 308,652Q319,663 336,663Q353,663 364,652L480,536ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||||
|
|
||||||
|
</vector>
|
Loading…
Reference in New Issue