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.
implementation("io.coil-kt:coil-compose:2.6.0")
implementation("com.google.zxing:core:3.5.1")
// Tailscale dependencies.
implementation ':libtailscale@aar'

@ -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<String?> = MutableStateFlow(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -93,6 +107,8 @@ class MainActivity : ComponentActivity() {
setContent {
AppTheme {
val navController = rememberNavController()
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
NavHost(
navController = navController,
startDestination = "main",
@ -150,7 +166,8 @@ class MainActivity : ComponentActivity() {
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable(
"mullvad/{countryCode}",
arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) {
arguments =
listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
}
@ -175,7 +192,8 @@ class MainActivity : ComponentActivity() {
})
}
composable("permissions") {
PermissionsView(nav = backNav, openApplicationSettings = ::openApplicationSettings)
PermissionsView(
nav = backNav, openApplicationSettings = ::openApplicationSettings)
}
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 {
Notifier.readyToPrepareVPN.collect { isReady ->
if (isReady)
@ -196,12 +222,28 @@ 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) {
// 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 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)
}
}
}
}
}

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

@ -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.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,9 +27,8 @@ import com.tailscale.ipn.R
@Composable
fun IntroView(onContinue: () -> Unit) {
Surface {
Column(
modifier = Modifier.fillMaxHeight(),
modifier = Modifier.fillMaxHeight().fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center) {
Image(
@ -61,4 +60,3 @@ fun IntroView(onContinue: () -> Unit) {
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.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,

@ -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
@ -38,11 +40,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,

@ -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="_continue">Continue</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 -->
<string name="app_name">Tailscale</string>
@ -222,5 +223,6 @@
<string name="getStarted">Get Started</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="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
</resources>

Loading…
Cancel
Save