Skip to content

Commit

Permalink
android/tv: reduce layout width and fix navigation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
barnstar committed Apr 5, 2024
1 parent 2e237e3 commit 3bf23cf
Show file tree
Hide file tree
Showing 23 changed files with 660 additions and 407 deletions.
1 change: 1 addition & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
221 changes: 130 additions & 91 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ 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.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
Expand All @@ -37,12 +40,15 @@ 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.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
Expand All @@ -57,9 +63,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() {
Expand All @@ -79,6 +86,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)

Expand All @@ -93,97 +105,108 @@ 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) }
Surface(color = MaterialTheme.colorScheme.inverseSurface) {
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(
"mullvad/{countryCode}",
arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "")
}
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
}
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("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("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. This will dismiss automatically on a starting
// state transition.
loginQRCode.collectAsState().value?.let {
LoginQRView(onDismiss = { loginQRCode.set(null) })
}
}
}
Expand All @@ -196,11 +219,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
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) {
Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/util/AndroidTVUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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
}
}
Loading

0 comments on commit 3bf23cf

Please sign in to comment.