android/ui: navigation improvements

1. More careful back navigation to avoid navigating to blank screen
2. After adding an account, navigate back to main view

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/297/head
Percy Wegmann 4 weeks ago committed by Percy Wegmann
parent 32e407d06b
commit d676dca4f4

@ -3,6 +3,7 @@
package com.tailscale.ipn
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
@ -30,6 +31,7 @@ import androidx.compose.ui.Modifier
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -45,7 +47,6 @@ 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
@ -73,14 +74,12 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var requestVpnPermission: ActivityResultLauncher<Unit>
private lateinit var navController: NavHostController
companion object {
// Request codes for Android callbacks.
// requestPrepareVPN is for when Android's VpnService.prepare completes.
@JvmStatic val requestPrepareVPN: Int = 1001
const val WRITE_STORAGE_RESULT = 1000
private const val TAG = "Main Activity"
private const val START_AT_ROOT = "startAtRoot"
}
private fun Context.isLandscapeCapable(): Boolean {
@ -93,6 +92,7 @@ class MainActivity : ComponentActivity() {
// simply opening the URL. This should be consumed once it has been handled.
private val loginQRCode: StateFlow<String?> = MutableStateFlow(null)
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -124,6 +124,10 @@ class MainActivity : ComponentActivity() {
popExitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it })
}) {
fun backTo(route: String): () -> Unit = {
navController.popBackStack(route = route, inclusive = false)
}
val mainViewNav =
MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
@ -143,19 +147,17 @@ class MainActivity : ComponentActivity() {
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onNavigateToPermissions = { navController.navigate("permissions") },
onBackPressed = { navController.popBackStack() },
)
val backNav = BackNavigation(onBack = { navController.popBackStack() })
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
ExitNodePickerNav(
onNavigateHome = {
onNavigateBackHome = {
navController.popBackStack(route = "main", inclusive = false)
},
onNavigateBack = { navController.popBackStack() },
onNavigateToExitNodePicker = { navController.popBackStack() },
onNavigateBackToExitNodes = backTo("exitNodes"),
onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateBackToMullvad = backTo("mullvad"),
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
@ -176,26 +178,21 @@ class MainActivity : ComponentActivity() {
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "")
PeerDetails(backTo("main"), 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("bugReport") { BugReportView(backTo("settings")) }
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("about") { AboutView(backTo("settings")) }
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
composable("managedBy") { ManagedByView(backTo("settings")) }
composable("userSwitcher") {
UserSwitcherView(
nav = backNav,
onNavigateHome = {
navController.popBackStack(route = "main", inclusive = false)
})
UserSwitcherView(backTo("settings"), backTo("main"))
}
composable("permissions") {
PermissionsView(
nav = backNav, openApplicationSettings = ::openApplicationSettings)
PermissionsView(backTo("settings"), ::openApplicationSettings)
}
composable("intro") { IntroView { navController.popBackStack() } }
composable("intro") { IntroView(backTo("main")) }
}
// Show the intro screen one time
@ -245,6 +242,13 @@ class MainActivity : ComponentActivity() {
return AndroidTVUtil.isAndroidTV()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent?.getBooleanExtra(START_AT_ROOT, false) == true) {
navController.popBackStack(route = "main", inclusive = false)
}
}
private fun login(urlString: String) {
// Launch coroutine to listen for state changes. When the user completes login, relaunch
// MainActivity to bring the app back to focus.
@ -257,6 +261,7 @@ class MainActivity : ComponentActivity() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
putExtra(START_AT_ROOT, true)
}
startActivity(intent)

@ -30,8 +30,9 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links
@Composable
fun AboutView(nav: BackNavigation) {
Scaffold(topBar = { Header(R.string.about_view_title, onBack = nav.onBack) }) { innerPadding ->
fun AboutView(backToSettings: BackNavigation) {
Scaffold(topBar = { Header(R.string.about_view_title, onBack = backToSettings) }) { innerPadding
->
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),

@ -29,11 +29,12 @@ import com.tailscale.ipn.ui.util.ClipboardValueView
import com.tailscale.ipn.ui.viewModel.BugReportViewModel
@Composable
fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel()) {
fun BugReportView(backToSettings: BackNavigation, model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current
val bugReportID = model.bugReportID.collectAsState().value
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = nav.onBack) }) { innerPadding ->
Scaffold(topBar = { Header(R.string.bug_report_title, onBack = backToSettings) }) { innerPadding
->
Column(modifier = Modifier.padding(innerPadding).fillMaxWidth().fillMaxHeight()) {
ListItem(
headlineContent = {

@ -35,7 +35,7 @@ data class ViewableRoute(val name: String, val resolvers: List<DnsType.Resolver>
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DNSSettingsView(
nav: BackNavigation,
backToSettings: BackNavigation,
model: DNSSettingsViewModel = viewModel(factory = DNSSettingsViewModelFactory())
) {
val state: DNSEnablementState = model.enablementState.collectAsState().value
@ -47,7 +47,7 @@ fun DNSSettingsView(
} ?: emptyList()
val useCorpDNS = Notifier.prefs.collectAsState().value?.CorpDNS == true
Scaffold(topBar = { Header(R.string.dns_settings, onBack = nav.onBack) }) { innerPadding ->
Scaffold(topBar = { Header(R.string.dns_settings, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(Modifier.padding(innerPadding)) {
item("state") {

@ -38,7 +38,7 @@ fun ExitNodePicker(
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBack) }) {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value
val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value

@ -24,9 +24,9 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MDMSettingsDebugView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = nav.onBack) }) { innerPadding
->
fun MDMSettingsDebugView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(MDMSettings.allSettings.sortedBy { "${it::class.java.name}|${it.key}" }) {
setting ->

@ -21,8 +21,8 @@ import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable
fun ManagedByView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.managed_by, onBack = nav.onBack) }) { innerPadding ->
fun ManagedByView(backToSettings: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.managed_by, onBack = backToSettings) }) { innerPadding ->
Column(
verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),

@ -40,7 +40,7 @@ fun MullvadExitNodePicker(
topBar = {
Header(
title = { Text("${countryCode.flag()} ${any.country}") },
onBack = nav.onNavigateBack)
onBack = nav.onNavigateBackToExitNodes)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) {

@ -36,60 +36,63 @@ fun MullvadExitNodePickerList(
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBack) }) {
innerPadding ->
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
Scaffold(
topBar = {
Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToMullvad)
}) { innerPadding ->
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
val sortedCountries =
mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
val sortedCountries =
mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast
// to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.
Box {
ListItem(
modifier =
Modifier.clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(countryCode)
} else {
model.setExitNode(first)
}
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be
// cast
// to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.
Box {
ListItem(
modifier =
Modifier.clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(countryCode)
} else {
model.setExitNode(first)
}
},
leadingContent = {
Text(
countryCode.flag(),
style = MaterialTheme.typography.titleLarge,
)
},
headlineContent = {
Text(first.country, style = MaterialTheme.typography.bodyMedium)
},
leadingContent = {
Text(
countryCode.flag(),
style = MaterialTheme.typography.titleLarge,
)
},
headlineContent = {
Text(first.country, style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
Text(
if (nodes.size == 1) first.city
else "${nodes.size} ${stringResource(R.string.cities_available)}",
style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
if (nodes.size > 1 && nodes.selected || first.selected) {
if (nodes.selected) {
Icon(
Icons.Outlined.Check,
contentDescription = stringResource(R.string.selected))
}
}
})
supportingContent = {
Text(
if (nodes.size == 1) first.city
else "${nodes.size} ${stringResource(R.string.cities_available)}",
style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
if (nodes.size > 1 && nodes.selected || first.selected) {
if (nodes.selected) {
Icon(
Icons.Outlined.Check,
contentDescription = stringResource(R.string.selected))
}
}
})
}
}
}
}
}
}
}
}

@ -43,7 +43,7 @@ import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerDetails(
nav: BackNavigation,
backToHome: BackNavigation,
nodeId: String,
model: PeerDetailsViewModel =
viewModel(factory = PeerDetailsViewModelFactory(nodeId, LocalContext.current.filesDir))
@ -74,7 +74,7 @@ fun PeerDetails(
}
}
},
onBack = { nav.onBack() })
onBack = backToHome)
},
) { innerPadding ->
LazyColumn(

@ -25,10 +25,10 @@ import com.tailscale.ipn.ui.util.itemsWithDividers
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionsView(nav: BackNavigation, openApplicationSettings: () -> Unit) {
fun PermissionsView(backToSettings: BackNavigation, openApplicationSettings: () -> Unit) {
val permissions = Permissions.withGrantedStatus
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = nav.onBack) }) { innerPadding
->
Scaffold(topBar = { Header(titleRes = R.string.permissions, onBack = backToSettings) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
itemsWithDividers(permissions) { (permission, granted) ->
ListItem(

@ -40,7 +40,7 @@ fun RunExitNodeView(
val isRunningExitNode = model.isRunningExitNode.collectAsState().value
Scaffold(
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateToExitNodePicker) }) {
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateBackToExitNodes) }) {
innerPadding ->
LoadingIndicator.Wrap {
Column(

@ -4,6 +4,7 @@
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.padding
import androidx.compose.foundation.rememberScrollState
@ -41,8 +42,9 @@ fun SettingsView(settingsNav: SettingsNav, viewModel: SettingsViewModel = viewMo
val isAdmin = viewModel.isAdmin.collectAsState().value
val managedByOrganization = viewModel.managedByOrganization.collectAsState().value
Scaffold(topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onBackPressed)
Scaffold(
topBar = {
Header(titleRes = R.string.settings_title, onBack = settingsNav.onNavigateBackHome)
}) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).verticalScroll(rememberScrollState())) {
UserView(

@ -29,9 +29,7 @@ import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.theme.topAppBar
import com.tailscale.ipn.ui.theme.ts_color_light_blue
data class BackNavigation(
val onBack: () -> Unit,
)
typealias BackNavigation = () -> Unit
// Header view for all secondary screens
// @see TopAppBar actions for additional actions (usually a row of icons)

@ -37,14 +37,14 @@ import com.tailscale.ipn.ui.viewModel.TailnetLockSetupViewModelFactory
@Composable
fun TailnetLockSetupView(
nav: BackNavigation,
backToSettings: BackNavigation,
model: TailnetLockSetupViewModel = viewModel(factory = TailnetLockSetupViewModelFactory())
) {
val statusItems = model.statusItems.collectAsState().value
val nodeKey = model.nodeKey.collectAsState().value
val tailnetLockKey = model.tailnetLockKey.collectAsState().value
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = nav.onBack) }) { innerPadding ->
Scaffold(topBar = { Header(R.string.tailnet_lock, onBack = backToSettings) }) { innerPadding ->
LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") { ExplainerView() }

@ -43,7 +43,7 @@ import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserSwitcherView(
nav: BackNavigation,
backToSettings: BackNavigation,
onNavigateHome: () -> Unit,
viewModel: UserSwitcherViewModel = viewModel()
) {
@ -56,7 +56,7 @@ fun UserSwitcherView(
topBar = {
Header(
R.string.accounts,
onBack = nav.onBack,
onBack = backToSettings,
actions = {
Row {
FusMenu(viewModel = viewModel)

@ -20,10 +20,10 @@ import kotlinx.coroutines.launch
import java.util.TreeMap
data class ExitNodePickerNav(
val onNavigateHome: () -> Unit,
val onNavigateBack: () -> Unit,
val onNavigateToExitNodePicker: () -> Unit,
val onNavigateBackHome: () -> Unit,
val onNavigateBackToExitNodes: () -> Unit,
val onNavigateToMullvad: () -> Unit,
val onNavigateBackToMullvad: () -> Unit,
val onNavigateToMullvadCountry: (String) -> Unit,
val onNavigateToRunAsExitNode: () -> Unit,
)
@ -138,7 +138,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateHome()
nav.onNavigateBackHome()
LoadingIndicator.stop()
}
}

@ -20,7 +20,8 @@ data class SettingsNav(
val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit,
val onNavigateToPermissions: () -> Unit,
val onBackPressed: () -> Unit,
val onNavigateBackHome: () -> Unit,
val onBackToSettings: () -> Unit,
)
class SettingsViewModel() : IpnViewModel() {

Loading…
Cancel
Save