From 94a4f55eb286bcbc51f4b5c2ffab0640bfb6f4f8 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Wed, 13 Mar 2024 16:06:03 -0400 Subject: [PATCH] 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 Co-authored-by: Andrea Gottardo --- .../java/com/tailscale/ipn/MainActivity.kt | 3 +- .../main/java/com/tailscale/ipn/ui/Links.kt | 26 ++++ .../tailscale/ipn/ui/service/IpnManager.kt | 12 +- .../com/tailscale/ipn/ui/view/AboutView.kt | 119 ++++++++++++++++++ .../tailscale/ipn/ui/view/BugReportView.kt | 113 +++++++++++++++++ .../com/tailscale/ipn/ui/view/MainView.kt | 21 ++-- .../com/tailscale/ipn/ui/view/PeerDetails.kt | 53 ++++---- .../com/tailscale/ipn/ui/view/SettingsView.kt | 94 ++++++++------ .../ipn/ui/viewModel/BugReportViewModel.kt | 29 +++++ .../ipn/ui/viewModel/PeerDetailsViewModel.kt | 13 +- .../ipn/ui/viewModel/SettingsViewModel.kt | 29 +---- android/src/main/res/values/strings.xml | 44 +++++++ 12 files changed, 450 insertions(+), 106 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/Links.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 6804693..2322f29 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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() diff --git a/android/src/main/java/com/tailscale/ipn/ui/Links.kt b/android/src/main/java/com/tailscale/ipn/ui/Links.kt new file mode 100644 index 0000000..c72cc3f --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/Links.kt @@ -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" +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt index fc79618..41f8e75 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob - typealias PrefChangeCallback = (Result) -> 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))) + } + } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt new file mode 100644 index 0000000..05146e5 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/AboutView.kt @@ -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 + ) + ) +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt new file mode 100644 index 0000000..478a51c --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/BugReportView.kt @@ -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) { + 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 +} + diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 9945a98..bbc3f54 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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, peers: StateFlow>, 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( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt index 5baf479..9238457 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt @@ -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) + } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt index c1c5612..84db174 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt @@ -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 } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt new file mode 100644 index 0000000..5d72334 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt @@ -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 = MutableStateFlow("") + var bugReportID: StateFlow = _bugReportID + + init { + viewModelScope.launch { + localAPI.getBugReportId { + when (it.successful) { + true -> _bugReportID.value = it.success ?: "(Error fetching ID)" + false -> _bugReportID.value = "(Error fetching ID)" + } + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index 6fb2e4f..4d0431b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -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 = 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 } } \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 9701e6c..130ba9d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -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 = MutableStateFlow(false), val value: MutableStateFlow? = 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 - } -} \ No newline at end of file +} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 07b3455..62d9807 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -1,5 +1,49 @@ + + + Log In + Log Out + None + Connect + Unknown User + Connected + Not Connected + + Tailscale Tailscale + Tailscale for Android + Acknowledgements + Privacy Policy + Terms of Service + 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. + The Tailscale App Icon + + + Report a Bug + To report a bug,  + contact our support team  + and include the ID below. + This ID helps us find the event ino our diagnostic logs. This process does not share any of your personally-identifiable information. + + + Settings + You can manage your account from the admin console.  + View admin console... + About + Bug Report + Use Tailscale DNS + + + Exit Node + Staring... + "Connect to your %1$s tailnet" + + + TAILSCALE ADDRESSES + OS + Key Expiry + +