android/tv: reduce layout width and fix navigation

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 <jonathan@tailscale.com>
jonathan/androidTV
Jonathan Nobels 7 months ago
parent 2e237e375e
commit ff73aad170

@ -104,6 +104,7 @@ dependencies {
// Supporting libraries. // Supporting libraries.
implementation("io.coil-kt:coil-compose:2.6.0") implementation("io.coil-kt:coil-compose:2.6.0")
implementation("com.google.zxing:core:3.5.1")
// Tailscale dependencies. // Tailscale dependencies.
implementation ':libtailscale@aar' implementation ':libtailscale@aar'

@ -23,6 +23,10 @@ import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally 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.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope 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.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme 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.AboutView
import com.tailscale.ipn.ui.view.BackNavigation import com.tailscale.ipn.ui.view.BackNavigation
import com.tailscale.ipn.ui.view.BugReportView import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.DNSSettingsView import com.tailscale.ipn.ui.view.DNSSettingsView
import com.tailscale.ipn.ui.view.ExitNodePicker import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.IntroView 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.MDMSettingsDebugView
import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation 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.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.SettingsNav import com.tailscale.ipn.ui.viewModel.SettingsNav
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -79,6 +88,11 @@ class MainActivity : ComponentActivity() {
SCREENLAYOUT_SIZE_LARGE 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<String?> = MutableStateFlow(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -93,6 +107,8 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
AppTheme { AppTheme {
val navController = rememberNavController() val navController = rememberNavController()
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "main", startDestination = "main",
@ -150,7 +166,8 @@ class MainActivity : ComponentActivity() {
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable( composable(
"mullvad/{countryCode}", "mullvad/{countryCode}",
arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) { arguments =
listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker( MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav) it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
} }
@ -175,7 +192,8 @@ class MainActivity : ComponentActivity() {
}) })
} }
composable("permissions") { composable("permissions") {
PermissionsView(nav = backNav, openApplicationSettings = ::openApplicationSettings) PermissionsView(
nav = backNav, openApplicationSettings = ::openApplicationSettings)
} }
composable("intro") { IntroView { navController.popBackStack() } } composable("intro") { IntroView { navController.popBackStack() } }
} }
@ -187,6 +205,14 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
// 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) })
}
}
}
lifecycleScope.launch { lifecycleScope.launch {
Notifier.readyToPrepareVPN.collect { isReady -> Notifier.readyToPrepareVPN.collect { isReady ->
if (isReady) if (isReady)
@ -196,12 +222,28 @@ class MainActivity : ComponentActivity() {
} }
init { init {
// Watch the model's browseToURL and launch the browser when it changes // Watch the model's browseToURL and launch the browser when it changes or
// This will trigger the login flow // pop up a QR code to scan
lifecycleScope.launch { 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) { private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch // Launch coroutine to listen for state changes. When the user completes login, relaunch

@ -11,11 +11,15 @@ import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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 androidx.lifecycle.lifecycleScope
import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.universalFit
import com.tailscale.ipn.ui.view.TaildropView import com.tailscale.ipn.ui.view.TaildropView
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -29,7 +33,13 @@ class ShareActivity : ComponentActivity() {
override fun onCreate(state: Bundle?) { override fun onCreate(state: Bundle?) {
super.onCreate(state) super.onCreate(state)
setContent { 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)
}
}
}
} }
} }

@ -75,7 +75,7 @@ private val LightColors =
outlineVariant = Color(0xFFEDEBEA), // gray-200 outlineVariant = Color(0xFFEDEBEA), // gray-200
inverseSurface = Color(0xFF232222), // gray-800 inverseSurface = Color(0xFF232222), // gray-800
inverseOnSurface = Color(0xFFFFFFFF), // white inverseOnSurface = Color(0xFFFFFFFF), // white
scrim = Color(0xFF000000), // black scrim = Color(0xAA000000), // black
) )
val ColorScheme.warning: Color val ColorScheme.warning: Color

@ -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
}
}

@ -9,12 +9,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button import androidx.compose.material3.Button
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
@ -27,9 +27,8 @@ import com.tailscale.ipn.R
@Composable @Composable
fun IntroView(onContinue: () -> Unit) { fun IntroView(onContinue: () -> Unit) {
Surface {
Column( Column(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight().fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) { verticalArrangement = Arrangement.Center) {
Image( Image(
@ -60,5 +59,4 @@ fun IntroView(onContinue: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center) textAlign = TextAlign.Center)
} }
}
} }

@ -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)) }
}
}
}
}

@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
@ -126,7 +127,16 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
when (user) { when (user) {
null -> SettingsButton { navigation.onNavigateToSettings() } null -> SettingsButton { navigation.onNavigateToSettings() }
else -> 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 @Composable
fun SettingsButton(action: () -> Unit) { 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() }) { IconButton(modifier = Modifier.size(24.dp), onClick = { action() }) {
Icon( Icon(
Icons.Outlined.Settings, Icons.Outlined.Settings,

@ -6,7 +6,9 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -38,11 +40,10 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
val isAdmin = viewModel.isAdmin.collectAsState().value val isAdmin = viewModel.isAdmin.collectAsState().value
val managedByOrganization = viewModel.managedByOrganization.collectAsState().value val managedByOrganization = viewModel.managedByOrganization.collectAsState().value
Scaffold( Scaffold(topBar = {
topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onBackPressed) Header(titleRes = R.string.settings_title, onBack = settingsNav.onBackPressed)
}) { innerPadding -> }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
UserView( UserView(
profile = user, profile = user,
actionState = UserActionState.NAV, actionState = UserActionState.NAV,

@ -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<ImageBitmap?> = 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, Any?>(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()
}
}

@ -17,7 +17,8 @@
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="_continue">Continue</string> <string name="_continue">Continue</string>
<string name="warning">Warning</string> <string name="warning">Warning</string>
<string name="search">Search\n</string> <string name="search">Search</string>
<string name="dismiss">Dismiss</string>
<!-- Strings for the about screen --> <!-- Strings for the about screen -->
<string name="app_name">Tailscale</string> <string name="app_name">Tailscale</string>
@ -222,5 +223,6 @@
<string name="getStarted">Get Started</string> <string name="getStarted">Get Started</string>
<string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices.</string> <string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices.</string>
<string name="welcome2">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.</string> <string name="welcome2">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.</string>
<string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
</resources> </resources>

Loading…
Cancel
Save