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 2 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.Settings
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.MainViewModel
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
@ -72,7 +73,7 @@ class MainActivity : ComponentActivity() {
?: ""))
}
composable("bugReport") {
BugReportView()
BugReportView(BugReportViewModel(manager.apiClient))
}
composable("about") {
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.SupervisorJob
typealias PrefChangeCallback = (Result<Boolean>) -> Unit
// 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 login: () -> Unit,
val logout: () -> Unit,
val openAdminConsole: () -> Unit,
val updatePrefs: (Ipn.MaskedPrefs, PrefChangeCallback) -> Unit
)
@ -42,7 +40,6 @@ class IpnManager {
stopVPN = { stopVPN() },
login = { apiClient.startLoginInteractive() },
logout = { apiClient.logout() },
openAdminConsole = { /* TODO */ },
updatePrefs = { prefs, callback -> updatePrefs(prefs, callback) }
)
@ -62,7 +59,12 @@ class IpnManager {
}
fun updatePrefs(prefs: Ipn.MaskedPrefs, callback: PrefChangeCallback) {
// (jonathan) TODO: Implement this in localAPI
//apiClient.updatePrefs(prefs)
apiClient.editPrefs(prefs) { result ->
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
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.
if (state.value == Ipn.State.Running) {
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, "None")
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, stringResource(id = R.string.none))
}
when (state.value) {
@ -105,7 +107,7 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
}
@Composable
fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = "None") {
fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = stringResource(id = R.string.none)) {
Box(modifier = Modifier
.clickable { navAction() }
.padding(horizontal = 8.dp)
@ -113,7 +115,7 @@ fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = "None") {
.background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
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 {
Text(text = exitNode, style = MaterialTheme.typography.bodyMedium)
Icon(
@ -159,7 +161,7 @@ fun StartingView() {
.background(MaterialTheme.colorScheme.secondaryContainer),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { Text(text = "Starting...", style = MaterialTheme.typography.titleMedium) }
) { Text(text = stringResource(id = R.string.starting), style = MaterialTheme.typography.titleMedium) }
}
@Composable
@ -172,16 +174,15 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
verticalArrangement = Arrangement.Center,
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()) {
val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(
"Connect to your ${tailnetName} tailnet",
Text(stringResource(id = R.string.connect_to_tailnet, tailnetName),
style = MaterialTheme.typography.bodyMedium
)
Button(onClick = connectAction) { Text(text = "Connect") }
Button(onClick = connectAction) { Text(text = stringResource(id = R.string.connect)) }
} 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 ->
ListItem(headlineContent = {
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 ->
ListItem(

@ -18,50 +18,55 @@ 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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
@Composable
fun PeerDetails(viewModel: PeerDetailsViewModel) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Column(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = viewModel.nodeName, style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier
.size(8.dp)
.background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = viewModel.connectedStr, style = MaterialTheme.typography.bodyMedium)
Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
Column(modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = viewModel.nodeName, style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier
.size(8.dp)
.background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = 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()) {
viewModel.addresses.forEach {
AddressRow(address = it.address, type = it.typeString)
Column(modifier = settingsRowModifier()) {
viewModel.addresses.forEach {
AddressRow(address = it.address, type = it.typeString)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) {
viewModel.info.forEach {
ValueRow(title = it.title, value = it.value)
Column(modifier = settingsRowModifier()) {
viewModel.info.forEach {
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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
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.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
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.model.IpnLocal
import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier
@ -40,44 +49,51 @@ data class SettingsNav(
@Composable
fun Settings(viewModel: SettingsViewModel) {
Column(modifier = defaultPaddingModifier()) {
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")
}
}
val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) {
Spacer(modifier = Modifier.height(8.dp))
viewModel.settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) {
settingBundle.title?.let {
Text(text = it, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
Column(modifier = defaultPaddingModifier()) {
viewModel.user?.let { user ->
UserView(profile = user, viewModel.isAdmin, adminText(), onClick = {
handler.openUri(Links.ADMIN_URL)
})
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.ipnActions.logout() }) {
Text(text = stringResource(id = R.string.log_out))
}
settingBundle.settings.forEach { setting ->
when (setting.type) {
SettingType.NAV -> {
SettingsNavRow(setting)
}
} ?: run {
Button(onClick = { viewModel.ipnActions.login() }) {
Text(text = stringResource(id = R.string.log_in))
}
}
SettingType.SWITCH -> {
SettingsSwitchRow(setting)
}
SettingType.NAV_WITH_TEXT -> {
SettingsNavRow(setting)
Spacer(modifier = Modifier.height(8.dp))
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
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) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
}
@ -122,7 +138,7 @@ fun SettingsSwitchRow(setting: Setting) {
val enabled = setting.enabled.collectAsState().value
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) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
}
@ -130,11 +146,15 @@ fun SettingsSwitchRow(setting: Setting) {
}
@Composable
fun BugReportView() {
Text(text = "Future Home of Bug Reporting")
}
fun adminText(): AnnotatedString {
val annotatedString = buildAnnotatedString {
append(stringResource(id = R.string.settings_admin_prefix))
@Composable
fun AboutView() {
Text(text = "Future Home of About")
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(style = SpanStyle(color = Color.Blue)) {
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
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.viewModel
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.util.DisplayAddress
import com.tailscale.ipn.ui.util.TimeUtil
data class PeerSettingInfo(val title: String, val value: String)
data class PeerSettingInfo(val titleRes: Int, val value: String)
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()
val nodeName: String
val connectedStr: String
val connectedStrRes: Int
val connectedColor: Color
init {
@ -32,13 +32,14 @@ class PeerDetailsViewModel(val model: IpnModel, val nodeId: StableNodeID) : View
peer?.let { p ->
info = listOf(
PeerSettingInfo("OS", p.Hostinfo?.OS ?: ""),
PeerSettingInfo("Key Expiry", TimeUtil().keyExpiryFromGoTime(p.KeyExpiry))
PeerSettingInfo(R.string.os, p.Hostinfo?.OS ?: ""),
PeerSettingInfo(R.string.key_expiry, TimeUtil().keyExpiryFromGoTime(p.KeyExpiry))
)
}
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
}
}

@ -3,13 +3,9 @@
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.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel
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
// value
data class Setting(
val title: String,
val titleRes: Int,
val type: SettingType,
val enabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
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 useDNSSetting = Setting(
"Use Tailscale DNS",
R.string.use_ts_dns,
SettingType.SWITCH,
isOn = MutableStateFlow(model.prefs.value?.CorpDNS),
onToggle = {
@ -77,21 +73,8 @@ class SettingsViewModel(val model: IpnModel, val ipnActions: IpnActions, val nav
)),
// General settings, always enabled
SettingBundle(settings = listOf(
Setting("About", SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, enabled = MutableStateFlow(true)),
Setting("Bug Report", SettingType.NAV, onClick = { navigation.onNavigateToBugReport() }, enabled = MutableStateFlow(true))
Setting(R.string.about, SettingType.NAV, onClick = { navigation.onNavigateToAbout() }, 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"?>
<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="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>

Loading…
Cancel
Save