android: implement the bug reporting and about screen and localize (#198)

updates tailscale/corp#18202
fixes ENG-2876

Adds the bug reporting view.  Functional, but not properly styled.

Moves the various link URLs to a constants file and corrects link-opening in both but reporting and the settings screen.

Adds an AboutView with app icon and same content as the iOS version.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
pull/200/head
Jonathan Nobels 9 months ago committed by GitHub
parent 0d867aedce
commit 94a4f55eb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -27,6 +27,7 @@ import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.Settings import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.SettingsNav import com.tailscale.ipn.ui.view.SettingsNav
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
@ -72,7 +73,7 @@ class MainActivity : ComponentActivity() {
?: "")) ?: ""))
} }
composable("bugReport") { composable("bugReport") {
BugReportView() BugReportView(BugReportViewModel(manager.apiClient))
} }
composable("about") { composable("about") {
AboutView() AboutView()

@ -0,0 +1,26 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui
object Links {
const val DEFAULT_CONTROL_URL = "https://controlplane.tailscale.com"
const val SERVER_URL = "https://login.tailscale.com"
const val ADMIN_URL = SERVER_URL + "/admin"
const val SIGNIN_URL = "https://tailscale.com/login"
const val PRIVACY_POLICY_URL = "https://tailscale.com/privacy-policy/"
const val TERMS_URL = "https://tailscale.com/terms"
const val DOCS_URL = "https://tailscale.com/kb/"
const val START_GUIDE_URL = "https://tailscale.com/kb/1017/install/"
const val LICENSES_URL = "https://tailscale.com/licenses/android"
const val DELETE_ACCOUNT_URL = "https://login.tailscale.com/login?next_url=%2Fadmin%2Fsettings%2Fgeneral"
const val TAILNET_LOCK_KB_URL = "https://tailscale.com/kb/1226/tailnet-lock/"
const val KEY_EXPIRY_KB_URL = "https://tailscale.com/kb/1028/key-expiry/"
const val INSTALL_TAILSCALE_KB_URL = "https://tailscale.com/kb/installation/"
const val INSTALL_UNSTABLE_KB_URL = "https://tailscale.com/kb/1083/install-unstable"
const val MAGICDNS_KB_URL = "https://tailscale.com/kb/1081/magicdns"
const val TROUBLESHOOTING_KB_URL = "https://tailscale.com/kb/1023/troubleshooting"
const val SUPPORT_URL = "https://tailscale.com/contact/support#support-form"
const val TAILDROP_KB_URL = "https://tailscale.com/kb/1106/taildrop"
const val TAILFS_KB_URL = "https://tailscale.com/kb/1106/taildrop"
}

@ -15,7 +15,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
typealias PrefChangeCallback = (Result<Boolean>) -> Unit typealias PrefChangeCallback = (Result<Boolean>) -> Unit
// Abstracts the actions that can be taken by the UI so that the concept of an IPNManager // Abstracts the actions that can be taken by the UI so that the concept of an IPNManager
@ -25,7 +24,6 @@ data class IpnActions(
val stopVPN: () -> Unit, val stopVPN: () -> Unit,
val login: () -> Unit, val login: () -> Unit,
val logout: () -> Unit, val logout: () -> Unit,
val openAdminConsole: () -> Unit,
val updatePrefs: (Ipn.MaskedPrefs, PrefChangeCallback) -> Unit val updatePrefs: (Ipn.MaskedPrefs, PrefChangeCallback) -> Unit
) )
@ -42,7 +40,6 @@ class IpnManager {
stopVPN = { stopVPN() }, stopVPN = { stopVPN() },
login = { apiClient.startLoginInteractive() }, login = { apiClient.startLoginInteractive() },
logout = { apiClient.logout() }, logout = { apiClient.logout() },
openAdminConsole = { /* TODO */ },
updatePrefs = { prefs, callback -> updatePrefs(prefs, callback) } updatePrefs = { prefs, callback -> updatePrefs(prefs, callback) }
) )
@ -62,7 +59,12 @@ class IpnManager {
} }
fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) { fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) {
// (jonathan) TODO: Implement this in localAPI apiClient.editPrefs(prefs) { result ->
//apiClient.updatePrefs(prefs) result.success?.let {
callback(Result.success(true))
} ?: run {
callback(Result.failure(Throwable(result.error)))
}
}
} }
} }

@ -0,0 +1,119 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
@Composable
fun AboutView() {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(
verticalArrangement = Arrangement.spacedBy(
space = 20.dp, alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.safeContentPadding()
) {
Image(
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black)
.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
), horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
stringResource(R.string.about_view_title),
fontWeight = FontWeight.SemiBold,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
color = MaterialTheme.colorScheme.primary
)
Text(
text = 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
)
}
Text(
stringResource(R.string.about_view_footnotes),
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center
)
}
}
}
@Composable
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
)
)
}

@ -0,0 +1,113 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
import kotlinx.coroutines.flow.StateFlow
@Composable
fun BugReportView(viewModel: BugReportViewModel) {
val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier().fillMaxWidth()) {
Text(text = stringResource(id = R.string.bug_report_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
ClickableText(text = contactText(),
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
onClick = {
handler.openUri(Links.SUPPORT_URL)
})
Spacer(modifier = Modifier.height(8.dp))
ReportIdRow(bugReportIdFlow = viewModel.bugReportID)
Spacer(modifier = Modifier.height(8.dp))
Text(text = stringResource(id = R.string.bug_report_id_desc),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left,
style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
fun ReportIdRow(bugReportIdFlow: StateFlow<String>) {
val localClipboardManager = LocalClipboardManager.current
val bugReportId = bugReportIdFlow.collectAsState()
Row(modifier = settingsRowModifier()
.fillMaxWidth()
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(bugReportId.value)) }),
verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(10f)) {
Text(text = bugReportId.value, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = defaultPaddingModifier())
}
Box(Modifier.weight(1f)) {
Icon(Icons.Outlined.Share, null, modifier = Modifier
.width(24.dp)
.height(24.dp))
}
}
}
@Composable
fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
append(stringResource(id = R.string.bug_report_instructions_prefix))
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(style = SpanStyle(color = Color.Blue)) {
append(stringResource(id = R.string.bug_report_instructions_linktext))
}
pop()
append(stringResource(id = R.string.bug_report_instructions_suffix))
}
return annotatedString
}

@ -41,7 +41,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
@ -82,7 +84,7 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
// (jonathan) TODO: Show the selected exit node name here. // (jonathan) TODO: Show the selected exit node name here.
if (state.value == Ipn.State.Running) { if (state.value == Ipn.State.Running) {
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, "None") ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, stringResource(id = R.string.none))
} }
when (state.value) { when (state.value) {
@ -105,7 +107,7 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
} }
@Composable @Composable
fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = "None") { fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = stringResource(id = R.string.none)) {
Box(modifier = Modifier Box(modifier = Modifier
.clickable { navAction() } .clickable { navAction() }
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
@ -113,7 +115,7 @@ fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = "None") {
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) { .fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) { Column(modifier = Modifier.padding(6.dp)) {
Text(text = "Exit Node", style = MaterialTheme.typography.titleMedium) Text(text = stringResource(id = R.string.exit_node), style = MaterialTheme.typography.titleMedium)
Row { Row {
Text(text = exitNode, style = MaterialTheme.typography.bodyMedium) Text(text = exitNode, style = MaterialTheme.typography.bodyMedium)
Icon( Icon(
@ -159,7 +161,7 @@ fun StartingView() {
.background(MaterialTheme.colorScheme.secondaryContainer), .background(MaterialTheme.colorScheme.secondaryContainer),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { Text(text = "Starting...", style = MaterialTheme.typography.titleMedium) } ) { Text(text = stringResource(id = R.string.starting), style = MaterialTheme.typography.titleMedium) }
} }
@Composable @Composable
@ -172,16 +174,15 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text(text = "Not Connected", style = MaterialTheme.typography.titleMedium) Text(text = stringResource(id = R.string.not_connected), style = MaterialTheme.typography.titleMedium)
if (user != null && !user.isEmpty()) { if (user != null && !user.isEmpty()) {
val tailnetName = user.NetworkProfile?.DomainName ?: "" val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text( Text(stringResource(id = R.string.connect_to_tailnet, tailnetName),
"Connect to your ${tailnetName} tailnet",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
Button(onClick = connectAction) { Text(text = "Connect") } Button(onClick = connectAction) { Text(text = stringResource(id = R.string.connect)) }
} else { } else {
Button(onClick = loginAction) { Text(text = "Log In") } Button(onClick = loginAction) { Text(text = stringResource(id = R.string.log_in)) }
} }
} }
} }
@ -216,7 +217,7 @@ fun PeerList(searchTerm: StateFlow<String>, peers: StateFlow<List<PeerSet>>, onN
peerList.value.forEach { peerSet -> peerList.value.forEach { peerSet ->
ListItem(headlineContent = { ListItem(headlineContent = {
Text(text = peerSet.user?.DisplayName Text(text = peerSet.user?.DisplayName
?: "Unknown User", style = MaterialTheme.typography.titleLarge) ?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
}) })
peerSet.peers.forEach { peer -> peerSet.peers.forEach { peer ->
ListItem( ListItem(

@ -18,50 +18,55 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
@Composable @Composable
fun PeerDetails(viewModel: PeerDetailsViewModel) { fun PeerDetails(viewModel: PeerDetailsViewModel) {
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Column(modifier = Modifier Column(modifier = Modifier.padding(horizontal = 8.dp)) {
.fillMaxWidth() Column(modifier = Modifier
.padding(vertical = 8.dp), .fillMaxWidth()
horizontalAlignment = Alignment.CenterHorizontally) { .padding(vertical = 8.dp),
Text(text = viewModel.nodeName, style = MaterialTheme.typography.titleMedium) horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) { Text(text = viewModel.nodeName, style = MaterialTheme.typography.titleMedium)
Box(modifier = Modifier Row(verticalAlignment = Alignment.CenterVertically) {
.size(8.dp) Box(modifier = Modifier
.background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {} .size(8.dp)
Spacer(modifier = Modifier.size(8.dp)) .background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {}
Text(text = viewModel.connectedStr, style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(id = viewModel.connectedStrRes), style = MaterialTheme.typography.bodyMedium)
}
} }
}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = "TAILSCALE ADDRESSES", style = MaterialTheme.typography.titleMedium) Text(text = stringResource(id = R.string.addresses_section), style = MaterialTheme.typography.titleMedium)
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
viewModel.addresses.forEach { viewModel.addresses.forEach {
AddressRow(address = it.address, type = it.typeString) AddressRow(address = it.address, type = it.typeString)
}
} }
}
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
viewModel.info.forEach { viewModel.info.forEach {
ValueRow(title = it.title, value = it.value) ValueRow(title = stringResource(id = it.titleRes), value = it.value)
}
} }
} }
} }

@ -17,14 +17,23 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
@ -40,44 +49,51 @@ data class SettingsNav(
@Composable @Composable
fun Settings(viewModel: SettingsViewModel) { fun Settings(viewModel: SettingsViewModel) {
Column(modifier = defaultPaddingModifier()) { val handler = LocalUriHandler.current
viewModel.user?.let { user ->
UserView(profile = user, viewModel.isAdmin, viewModel.adminText(), onClick = { viewModel.ipnActions.openAdminConsole() })
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.ipnActions.logout() }) {
Text(text = "Log Out")
}
} ?: run {
Button(onClick = { viewModel.ipnActions.login() }) {
Text(text = "Sign In")
}
}
Surface(color = MaterialTheme.colorScheme.surface) {
Spacer(modifier = Modifier.height(8.dp)) Column(modifier = defaultPaddingModifier()) {
viewModel.user?.let { user ->
viewModel.settings.forEach { settingBundle -> UserView(profile = user, viewModel.isAdmin, adminText(), onClick = {
Column(modifier = settingsRowModifier()) { handler.openUri(Links.ADMIN_URL)
settingBundle.title?.let { })
Text(text = it, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.ipnActions.logout() }) {
Text(text = stringResource(id = R.string.log_out))
} }
settingBundle.settings.forEach { setting -> } ?: run {
when (setting.type) { Button(onClick = { viewModel.ipnActions.login() }) {
SettingType.NAV -> { Text(text = stringResource(id = R.string.log_in))
SettingsNavRow(setting) }
} }
SettingType.SWITCH -> {
SettingsSwitchRow(setting)
}
SettingType.NAV_WITH_TEXT -> { Spacer(modifier = Modifier.height(8.dp))
SettingsNavRow(setting)
viewModel.settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) {
settingBundle.title?.let {
Text(text = it, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
}
settingBundle.settings.forEach { setting ->
when (setting.type) {
SettingType.NAV -> {
SettingsNavRow(setting)
}
SettingType.SWITCH -> {
SettingsSwitchRow(setting)
}
SettingType.NAV_WITH_TEXT -> {
SettingsNavRow(setting)
}
} }
} }
} }
Spacer(modifier = Modifier.height(8.dp))
} }
Spacer(modifier = Modifier.height(8.dp))
} }
} }
} }
@ -108,7 +124,7 @@ fun SettingsNavRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value val enabled = setting.enabled.collectAsState().value
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) { Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }) {
Text(text = setting.title) Text(text = stringResource(id = setting.titleRes))
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
} }
@ -122,7 +138,7 @@ fun SettingsSwitchRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value val enabled = setting.enabled.collectAsState().value
Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) { Row(modifier = defaultPaddingModifier().clickable { if (enabled) setting.onClick() }, verticalAlignment = Alignment.CenterVertically) {
Text(text = setting.title) Text(text = stringResource(id = setting.titleRes))
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
} }
@ -130,11 +146,15 @@ fun SettingsSwitchRow(setting: Setting) {
} }
@Composable @Composable
fun BugReportView() { fun adminText(): AnnotatedString {
Text(text = "Future Home of Bug Reporting") val annotatedString = buildAnnotatedString {
} append(stringResource(id = R.string.settings_admin_prefix))
@Composable pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
fun AboutView() { withStyle(style = SpanStyle(color = Color.Blue)) {
Text(text = "Future Home of About") append(stringResource(id = R.string.settings_admin_link))
}
pop()
}
return annotatedString
} }

@ -0,0 +1,29 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.LocalApiClient
import com.tailscale.ipn.ui.model.BugReportID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() {
private var _bugReportID: MutableStateFlow<BugReportID> = MutableStateFlow("")
var bugReportID: StateFlow<String> = _bugReportID
init {
viewModelScope.launch {
localAPI.getBugReportId {
when (it.successful) {
true -> _bugReportID.value = it.success ?: "(Error fetching ID)"
false -> _bugReportID.value = "(Error fetching ID)"
}
}
}
}
}

@ -1,17 +1,17 @@
// Copyright (c) Tailscale Inc & AUTHORS // Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.util.DisplayAddress import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
data class PeerSettingInfo(val title: String, val value: String) data class PeerSettingInfo(val titleRes: Int, val value: String)
class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : ViewModel() { class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : ViewModel() {
@ -19,7 +19,7 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : View
var info: List<PeerSettingInfo> = emptyList() var info: List<PeerSettingInfo> = emptyList()
val nodeName: String val nodeName: String
val connectedStr: String val connectedStrRes: Int
val connectedColor: Color val connectedColor: Color
init { init {
@ -32,13 +32,14 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : View
peer?.let { p -> peer?.let { p ->
info = listOf( info = listOf(
PeerSettingInfo("OS", p.Hostinfo?.OS ?: ""), PeerSettingInfo(R.string.os, p.Hostinfo?.OS ?: ""),
PeerSettingInfo("Key Expiry", TimeUtil().keyExpiryFromGoTime(p.KeyExpiry)) PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry))
) )
} }
nodeName = peer?.ComputedName ?: "" nodeName = peer?.ComputedName ?: ""
connectedStr = if (peer?.Online == true) "Connected" else "Not Connected" connectedStrRes = if (peer?.Online == true) R.string.connected else R.string.not_connected
connectedColor = if (peer?.Online == true) Color.Green else Color.Gray connectedColor = if (peer?.Online == true) Color.Green else Color.Gray
} }
} }

@ -3,13 +3,9 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.service.IpnActions import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.toggleCorpDNS import com.tailscale.ipn.ui.service.toggleCorpDNS
@ -32,7 +28,7 @@ enum class SettingType { NAV, SWITCH, NAV_WITH_TEXT }
// isOn and onToggle, while navigation settings should supply an onClick and an optional // isOn and onToggle, while navigation settings should supply an onClick and an optional
// value // value
data class Setting( data class Setting(
val title: String, val titleRes: Int,
val type: SettingType, val type: SettingType,
val enabled: MutableStateFlow<Boolean> = MutableStateFlow(false), val enabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
val value: MutableStateFlow<String?>? = null, val value: MutableStateFlow<String?>? = null,
@ -52,7 +48,7 @@ class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val nav
val isAdmin = model.netmap.value?.SelfNode?.isAdmin ?: false val isAdmin = model.netmap.value?.SelfNode?.isAdmin ?: false
val useDNSSetting = Setting( val useDNSSetting = Setting(
"Use Tailscale DNS", R.string.use_ts_dns,
SettingType.SWITCH, SettingType.SWITCH,
isOn = MutableStateFlow(model.prefs.value?.CorpDNS), isOn = MutableStateFlow(model.prefs.value?.CorpDNS),
onToggle = { onToggle = {
@ -77,21 +73,8 @@ class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val nav
)), )),
// General settings, always enabled // General settings, always enabled
SettingBundle(settings = listOf( SettingBundle(settings = listOf(
Setting("About", SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, enabled = MutableStateFlow(true)), Setting(R.string.about, SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, enabled = MutableStateFlow(true)),
Setting("Bug Report", SettingType.NAV, onClick = { navigation.onNavigateToBugReport() }, enabled = MutableStateFlow(true)) Setting(R.string.bug_report, SettingType.NAV, onClick = { navigation.onNavigateToBugReport() }, enabled = MutableStateFlow(true))
)) ))
) )
}
fun adminText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
append("You can manage your account from the admin console. ")
pushStringAnnotation(tag = "policy", annotation = "https://google.com/policy")
withStyle(style = SpanStyle(color = Color.Blue)) {
append("View admin console...")
}
pop()
}
return annotatedString
}
}

@ -1,5 +1,49 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Generic Strings -->
<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="connected">Connected</string>
<string name="not_connected">Not Connected</string>
<!-- Strings for the about screen -->
<string name="app_name">Tailscale</string> <string name="app_name">Tailscale</string>
<string name="tile_name">Tailscale</string> <string name="tile_name">Tailscale</string>
<string name="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>
<!-- Strings for the bug reporting screen -->
<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 ID below.</string>
<string name="bug_report_id_desc">This ID helps us find the event ino our diagnostic logs. This process does not share any of your personally-identifiable information.</string>
<!-- 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="use_ts_dns">Use Tailscale DNS</string>
<!-- Strings for the main scren -->
<string name="exit_node">Exit Node</string>
<string name="starting">Staring...</string>
<string name="connect_to_tailnet">"Connect to your %1$s tailnet"</string>
<!-- Strings for peer details -->
<string name="addresses_section">TAILSCALE ADDRESSES</string>
<string name="os">OS</string>
<string name="key_expiry">Key Expiry</string>
</resources> </resources>

Loading…
Cancel
Save