From 32e407d06bbcfcbae491808a4f26c9f75e0fbb86 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Tue, 9 Apr 2024 07:40:31 -0400 Subject: [PATCH] android/tv: reduce layout width and fix navigation (#295) fixes tailscale/corp#18956 fixes tailscale/corp#18964 Adds a letterboxing effect as a temporary measure to make the UI a bit more usable on AndroidTV. Fixes a few navigation peculiarities specific to TV (notably, there some padding on the user avatar so you can see when it's highlighted) Pops a QR code on AndroidTV where we have no browser to complete the flow. Signed-off-by: Jonathan Nobels --- android/build.gradle | 1 + .../java/com/tailscale/ipn/MainActivity.kt | 226 +++++++++++------- .../java/com/tailscale/ipn/ShareActivity.kt | 12 +- .../java/com/tailscale/ipn/ui/theme/Theme.kt | 2 +- .../tailscale/ipn/ui/util/AndroidTVUtil.kt | 29 +++ .../com/tailscale/ipn/ui/view/IntroView.kt | 62 +++-- .../com/tailscale/ipn/ui/view/LoginQRView.kt | 68 ++++++ .../com/tailscale/ipn/ui/view/MainView.kt | 14 +- .../com/tailscale/ipn/ui/view/SettingsView.kt | 7 +- .../ipn/ui/viewModel/LoginQRViewModel.kt | 62 +++++ android/src/main/res/values/strings.xml | 4 +- 11 files changed, 354 insertions(+), 133 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt diff --git a/android/build.gradle b/android/build.gradle index 6f5161a..2c0bede 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -104,6 +104,7 @@ dependencies { // Supporting libraries. implementation("io.coil-kt:coil-compose:2.6.0") + implementation("com.google.zxing:core:3.5.1") // Tailscale dependencies. implementation ':libtailscale@aar' diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 00e6abd..aa5d738 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -23,6 +23,10 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.core.tween import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier import androidx.core.net.toUri import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.lifecycleScope @@ -37,12 +41,16 @@ import com.tailscale.ipn.mdm.MDMSettings import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.theme.AppTheme +import com.tailscale.ipn.ui.util.AndroidTVUtil +import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.view.AboutView import com.tailscale.ipn.ui.view.BackNavigation import com.tailscale.ipn.ui.view.BugReportView import com.tailscale.ipn.ui.view.DNSSettingsView import com.tailscale.ipn.ui.view.ExitNodePicker import com.tailscale.ipn.ui.view.IntroView +import com.tailscale.ipn.ui.view.LoginQRView import com.tailscale.ipn.ui.view.MDMSettingsDebugView import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainViewNavigation @@ -57,9 +65,10 @@ import com.tailscale.ipn.ui.view.TailnetLockSetupView import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.SettingsNav -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { @@ -79,6 +88,11 @@ class MainActivity : ComponentActivity() { SCREENLAYOUT_SIZE_LARGE } + // The loginQRCode is used to track whether or not we should be rendering a QR code + // to the user. This is used only on TV platforms with no browser in lieu of + // simply opening the URL. This should be consumed once it has been handled. + private val loginQRCode: StateFlow = MutableStateFlow(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -93,97 +107,109 @@ class MainActivity : ComponentActivity() { setContent { AppTheme { val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = "main", - enterTransition = { - slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it }) - }, - exitTransition = { - slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it }) - }, - popEnterTransition = { - slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it }) - }, - popExitTransition = { - slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it }) - }) { - val mainViewNav = - MainViewNavigation( - onNavigateToSettings = { navController.navigate("settings") }, - onNavigateToPeerDetails = { - navController.navigate("peerDetails/${it.StableID}") - }, - onNavigateToExitNodes = { navController.navigate("exitNodes") }, - ) - - val settingsNav = - SettingsNav( - onNavigateToBugReport = { navController.navigate("bugReport") }, - onNavigateToAbout = { navController.navigate("about") }, - onNavigateToDNSSettings = { navController.navigate("dnsSettings") }, - onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, - onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, - onNavigateToManagedBy = { navController.navigate("managedBy") }, - onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, - onNavigateToPermissions = { navController.navigate("permissions") }, - onBackPressed = { navController.popBackStack() }, - ) - - val backNav = BackNavigation(onBack = { navController.popBackStack() }) - - val exitNodePickerNav = - ExitNodePickerNav( - onNavigateHome = { - navController.popBackStack(route = "main", inclusive = false) - }, - onNavigateBack = { navController.popBackStack() }, - onNavigateToExitNodePicker = { navController.popBackStack() }, - onNavigateToMullvad = { navController.navigate("mullvad") }, - onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, - onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) - - composable("main") { MainView(navigation = mainViewNav) } - composable("settings") { SettingsView(settingsNav) } - navigation(startDestination = "list", route = "exitNodes") { - composable("list") { ExitNodePicker(exitNodePickerNav) } - composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } - composable( - "mullvad/{countryCode}", - arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) { - MullvadExitNodePicker( - it.arguments!!.getString("countryCode")!!, exitNodePickerNav) - } - composable("runExitNode") { RunExitNodeView(exitNodePickerNav) } - } - composable( - "peerDetails/{nodeId}", - arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) { - PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "") + Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox + Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV + NavHost( + navController = navController, + startDestination = "main", + enterTransition = { + slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it }) + }, + exitTransition = { + slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it }) + }, + popEnterTransition = { + slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it }) + }, + popExitTransition = { + slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it }) + }) { + val mainViewNav = + MainViewNavigation( + onNavigateToSettings = { navController.navigate("settings") }, + onNavigateToPeerDetails = { + navController.navigate("peerDetails/${it.StableID}") + }, + onNavigateToExitNodes = { navController.navigate("exitNodes") }, + ) + + val settingsNav = + SettingsNav( + onNavigateToBugReport = { navController.navigate("bugReport") }, + onNavigateToAbout = { navController.navigate("about") }, + onNavigateToDNSSettings = { navController.navigate("dnsSettings") }, + onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, + onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, + onNavigateToManagedBy = { navController.navigate("managedBy") }, + onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, + onNavigateToPermissions = { navController.navigate("permissions") }, + onBackPressed = { navController.popBackStack() }, + ) + + val backNav = BackNavigation(onBack = { navController.popBackStack() }) + + val exitNodePickerNav = + ExitNodePickerNav( + onNavigateHome = { + navController.popBackStack(route = "main", inclusive = false) + }, + onNavigateBack = { navController.popBackStack() }, + onNavigateToExitNodePicker = { navController.popBackStack() }, + onNavigateToMullvad = { navController.navigate("mullvad") }, + onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, + onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) + + composable("main") { MainView(navigation = mainViewNav) } + composable("settings") { SettingsView(settingsNav) } + navigation(startDestination = "list", route = "exitNodes") { + composable("list") { ExitNodePicker(exitNodePickerNav) } + composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } + composable( + "mullvad/{countryCode}", + arguments = + listOf(navArgument("countryCode") { type = NavType.StringType })) { + MullvadExitNodePicker( + it.arguments!!.getString("countryCode")!!, exitNodePickerNav) + } + composable("runExitNode") { RunExitNodeView(exitNodePickerNav) } } - composable("bugReport") { BugReportView(nav = backNav) } - composable("dnsSettings") { DNSSettingsView(nav = backNav) } - composable("tailnetLock") { TailnetLockSetupView(nav = backNav) } - composable("about") { AboutView(nav = backNav) } - composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) } - composable("managedBy") { ManagedByView(nav = backNav) } - composable("userSwitcher") { - UserSwitcherView( - nav = backNav, - onNavigateHome = { - navController.popBackStack(route = "main", inclusive = false) - }) - } - composable("permissions") { - PermissionsView(nav = backNav, openApplicationSettings = ::openApplicationSettings) - } - composable("intro") { IntroView { navController.popBackStack() } } + composable( + "peerDetails/{nodeId}", + arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) { + PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "") + } + composable("bugReport") { BugReportView(nav = backNav) } + composable("dnsSettings") { DNSSettingsView(nav = backNav) } + composable("tailnetLock") { TailnetLockSetupView(nav = backNav) } + composable("about") { AboutView(nav = backNav) } + composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) } + composable("managedBy") { ManagedByView(nav = backNav) } + composable("userSwitcher") { + UserSwitcherView( + nav = backNav, + onNavigateHome = { + navController.popBackStack(route = "main", inclusive = false) + }) + } + composable("permissions") { + PermissionsView( + nav = backNav, openApplicationSettings = ::openApplicationSettings) + } + composable("intro") { IntroView { navController.popBackStack() } } + } + + // Show the intro screen one time + if (!introScreenViewed()) { + navController.navigate("intro") + setIntroScreenViewed(true) } + } + } - // Show the intro screen one time - if (!introScreenViewed()) { - navController.navigate("intro") - setIntroScreenViewed(true) + // Login actions are app wide. If we are told about a browse-to-url, we should render it + // over whatever screen we happen to be on. + loginQRCode.collectAsState().value?.let { + LoginQRView(onDismiss = { loginQRCode.set(null) }) } } } @@ -196,11 +222,27 @@ class MainActivity : ComponentActivity() { } init { - // Watch the model's browseToURL and launch the browser when it changes - // This will trigger the login flow + // Watch the model's browseToURL and launch the browser when it changes or + // pop up a QR code to scan lifecycleScope.launch { - Notifier.browseToURL.collect { url -> url?.let { Dispatchers.Main.run { login(it) } } } + Notifier.browseToURL.collect { url -> + url?.let { + when (useQRCodeLogin()) { + false -> Dispatchers.Main.run { login(it) } + true -> loginQRCode.set(it) + } + } + } } + + // Once we see a loginFinished event, clear the QR code which will dismiss the QR dialog. + lifecycleScope.launch { Notifier.loginFinished.collect { _ -> loginQRCode.set(null) } } + } + + // Returns true if we should render a QR code instead of launching a browser + // for login requests + private fun useQRCodeLogin(): Boolean { + return AndroidTVUtil.isAndroidTV() } private fun login(urlString: String) { diff --git a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt index 8f2cbaa..5610580 100644 --- a/android/src/main/java/com/tailscale/ipn/ShareActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/ShareActivity.kt @@ -11,11 +11,15 @@ import android.provider.OpenableColumns import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.util.universalFit import com.tailscale.ipn.ui.view.TaildropView import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,7 +33,13 @@ class ShareActivity : ComponentActivity() { override fun onCreate(state: Bundle?) { super.onCreate(state) setContent { - AppTheme { TaildropView(requestedTransfers, (application as App).applicationScope) } + AppTheme { + Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox + Surface(modifier = Modifier.universalFit()) { + TaildropView(requestedTransfers, (application as App).applicationScope) + } + } + } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt index fce7c63..c045fc5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt @@ -75,7 +75,7 @@ private val LightColors = outlineVariant = Color(0xFFEDEBEA), // gray-200 inverseSurface = Color(0xFF232222), // gray-800 inverseOnSurface = Color(0xFFFFFFFF), // white - scrim = Color(0xFF000000), // black + scrim = Color(0xAA000000), // black ) val ColorScheme.warning: Color diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt new file mode 100644 index 0000000..ae20af0 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt @@ -0,0 +1,29 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import android.content.pm.PackageManager +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.App +import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV + +object AndroidTVUtil { + fun isAndroidTV(): Boolean { + return (App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || + App.appInstance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) + } +} + +// Applies a letterbox effect iff we're running on Android TV to reduce the overall width +// of the UI. +fun Modifier.universalFit(): Modifier { + return when (isAndroidTV()) { + true -> this.padding(horizontal = 150.dp, vertical = 10.dp).clip(RoundedCornerShape(10.dp)) + false -> this + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/IntroView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/IntroView.kt index d4f421a..a3b7470 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/IntroView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/IntroView.kt @@ -9,12 +9,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.width import androidx.compose.material3.Button 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 @@ -27,38 +27,36 @@ import com.tailscale.ipn.R @Composable fun IntroView(onContinue: () -> Unit) { - Surface { - Column( - modifier = Modifier.fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center) { - Image( - modifier = Modifier.width(80.dp).height(80.dp), - painter = painterResource(id = R.drawable.androidicon_light), - contentDescription = stringResource(R.string.app_icon_content_description)) - Spacer(modifier = Modifier.height(40.dp)) - Text( - modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp), - text = stringResource(R.string.welcome1), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center) - - Button(onClick = onContinue) { - Text( - text = stringResource(id = R.string.getStarted), - fontSize = MaterialTheme.typography.titleMedium.fontSize) - } - Spacer(modifier = Modifier.height(40.dp)) - } + Column( + modifier = Modifier.fillMaxHeight().fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Image( + modifier = Modifier.width(80.dp).height(80.dp), + painter = painterResource(id = R.drawable.androidicon_light), + contentDescription = stringResource(R.string.app_icon_content_description)) + Spacer(modifier = Modifier.height(40.dp)) + Text( + modifier = Modifier.padding(start = 40.dp, end = 40.dp, bottom = 40.dp), + text = stringResource(R.string.welcome1), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center) - Box( - modifier = Modifier.fillMaxHeight().padding(start = 20.dp, end = 20.dp, bottom = 40.dp), - contentAlignment = Alignment.BottomCenter) { + Button(onClick = onContinue) { Text( - text = stringResource(R.string.welcome2), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center) + text = stringResource(id = R.string.getStarted), + fontSize = MaterialTheme.typography.titleMedium.fontSize) } - } + Spacer(modifier = Modifier.height(40.dp)) + } + + Box( + modifier = Modifier.fillMaxHeight().padding(start = 20.dp, end = 20.dp, bottom = 40.dp), + contentAlignment = Alignment.BottomCenter) { + Text( + text = stringResource(R.string.welcome2), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center) + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt new file mode 100644 index 0000000..9e3af24 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/LoginQRView.kt @@ -0,0 +1,68 @@ +// 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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +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.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.viewModel.LoginQRViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginQRView(onDismiss: () -> Unit = {}, model: LoginQRViewModel = viewModel()) { + Surface(color = MaterialTheme.colorScheme.scrim, modifier = Modifier.fillMaxSize()) { + Dialog(onDismissRequest = onDismiss) { + val image = model.qrCode.collectAsState() + + Column( + modifier = + Modifier.clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.scan_to_connect_to_your_tailnet), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface) + Box( + modifier = + Modifier.size(200.dp) + .background(MaterialTheme.colorScheme.onSurface) + .fillMaxWidth(), + contentAlignment = Alignment.Center) { + image.value?.let { + Image( + bitmap = it, + contentDescription = "Scan to login", + modifier = Modifier.fillMaxSize()) + } + } + Button(onClick = onDismiss) { Text(text = stringResource(R.string.dismiss)) } + } + } + } +} 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 f289b1d..0c946e8 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowDropDown @@ -126,7 +127,16 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode when (user) { null -> SettingsButton { navigation.onNavigateToSettings() } else -> - Avatar(profile = user, size = 36) { navigation.onNavigateToSettings() } + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier.size(42.dp).clip(CircleShape).clickable { + navigation.onNavigateToSettings() + }) { + Avatar(profile = user, size = 36) { + navigation.onNavigateToSettings() + } + } } } }) @@ -216,8 +226,6 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { @Composable fun SettingsButton(action: () -> Unit) { - // (jonathan) TODO: On iOS this is the users avatar or a letter avatar. - IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) { Icon( Icons.Outlined.Settings, 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 af6045e..6d6259a 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 @@ -6,7 +6,9 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -39,11 +41,10 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo val isAdmin = viewModel.isAdmin.collectAsState().value val managedByOrganization = viewModel.managedByOrganization.collectAsState().value - Scaffold( - topBar = { + Scaffold(topBar = { Header(titleRes = R.string.settings_title, onBack = settingsNav.onBackPressed) }) { innerPadding -> - Column(modifier = Modifier.padding(innerPadding)) { + Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) { UserView( profile = user, actionState = UserActionState.NAV, diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt new file mode 100644 index 0000000..a3c5dcf --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/LoginQRViewModel.kt @@ -0,0 +1,62 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.lifecycle.viewModelScope +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.WriterException +import com.google.zxing.qrcode.QRCodeWriter +import com.tailscale.ipn.ui.notifier.Notifier +import com.tailscale.ipn.ui.util.set +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class LoginQRViewModel : IpnViewModel() { + + val qrCode: StateFlow = MutableStateFlow(null) + + init { + viewModelScope.launch { + Notifier.browseToURL.collect { url -> + url?.let { qrCode.set(generateQRCode(url, 200, 0)) } ?: run { qrCode.set(null) } + } + } + } + + fun generateQRCode(content: String, size: Int, padding: Int): ImageBitmap? { + val qrCodeWriter = QRCodeWriter() + + val encodeHints = mapOf(EncodeHintType.MARGIN to padding) + + val bitmapMatrix = + try { + qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, size, size, encodeHints) + } catch (ex: WriterException) { + return null + } + + val qrCode = + Bitmap.createBitmap( + size, + size, + Bitmap.Config.ARGB_8888, + ) + + for (x in 0 until size) { + for (y in 0 until size) { + val shouldColorPixel = bitmapMatrix?.get(x, y) ?: false + val pixelColor = if (shouldColorPixel) Color.BLACK else Color.WHITE + qrCode.setPixel(x, y, pixelColor) + } + } + + return qrCode.asImageBitmap() + } +} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 62953c4..a78dcc7 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -17,7 +17,8 @@ OK Continue Warning - Search\n + Search + Dismiss Tailscale @@ -223,5 +224,6 @@ Get Started Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network. + Scan this QR code to log in to your tailnet