Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,45 +22,64 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentYellow
import kotlinx.serialization.Serializable

private data object Home

// A marker interface is used to mark any routes that require login
private data object Profile : AppBackStack.RequiresLogin
private data object Login
// We use a sealed class for the route supertype because KotlinX Serialization handles polymorphic
// serialization of sealed classes automatically.
@Serializable
sealed class Route(val requiresLogin: Boolean = false) : NavKey
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'm not really a fan of using "Route" in the context of nav3 because we don't use that term at all in nav3 library (kdocs or naming). I think sticking to "Key" helps clarify its role and where its used in the APIs.


@Serializable
private data object Home : Route()

@Serializable
private data object Profile : Route(requiresLogin = true)

@Serializable
private data object Login : Route()

class ConditionalActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {


val appBackStack = remember {
AppBackStack(startRoute = Home, loginRoute = Login)
val backStack = rememberNavBackStack<Route>(Home)
val isLoggedInState = rememberSaveable {
mutableStateOf(false)
}

val navigator = Navigator(
backStack = backStack,
loginRoute = Login,
isLoggedInState = isLoggedInState
)
Comment on lines +66 to +70
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Navigator instance is created on every recomposition. This is inefficient and can lead to subtle bugs. It should be wrapped in remember to ensure the same instance is used across recompositions.

            val navigator = remember(backStack, isLoggedInState) {
                Navigator(
                    backStack = backStack,
                    loginRoute = Login,
                    isLoggedInState = isLoggedInState
                )
            }


NavDisplay(
backStack = appBackStack.backStack,
onBack = { appBackStack.remove() },
backStack = backStack,
onBack = { navigator.goBack() },
entryProvider = entryProvider {
entry<Home> {
ContentGreen("Welcome to Nav3. Logged in? ${appBackStack.isLoggedIn}") {
ContentGreen("Welcome to Nav3. Logged in? ${isLoggedInState.value}") {
Column {
Button(onClick = { appBackStack.add(Profile) }) {
Button(onClick = { navigator.navigate(Profile) }) {
Text("Profile")
}
Button(onClick = { appBackStack.add(Login) }) {
Button(onClick = { navigator.navigate(Login) }) {
Text("Login")
}
}
Expand All @@ -69,16 +88,16 @@ class ConditionalActivity : ComponentActivity() {
entry<Profile> {
ContentBlue("Profile screen (only accessible once logged in)") {
Button(onClick = {
appBackStack.logout()
navigator.logout()
}) {
Text("Logout")
}
}
}
entry<Login> {
ContentYellow("Login screen. Logged in? ${appBackStack.isLoggedIn}") {
ContentYellow("Login screen. Logged in? ${isLoggedInState.value}") {
Button(onClick = {
appBackStack.login()
navigator.login()
}) {
Text("Login")
}
Expand All @@ -90,52 +109,16 @@ class ConditionalActivity : ComponentActivity() {
}
}

/**
* A back stack that can handle routes that require login.
*
* @param startRoute The starting route
* @param loginRoute The route that users should be taken to when they attempt to access a route
* that requires login
*/
class AppBackStack<T : Any>(startRoute: T, private val loginRoute: T) {

interface RequiresLogin

private var onLoginSuccessRoute: T? = null

var isLoggedIn by mutableStateOf(false)
private set

val backStack = mutableStateListOf(startRoute)

fun add(route: T) {
if (route is RequiresLogin && !isLoggedIn) {
// Store the intended destination and redirect to login
onLoginSuccessRoute = route
backStack.add(loginRoute)
} else {
backStack.add(route)
}

// If the user explicitly requested the login route, don't redirect them after login
if (route == loginRoute) {
onLoginSuccessRoute = null
}
}

fun remove() = backStack.removeLastOrNull()

fun login() {
isLoggedIn = true

onLoginSuccessRoute?.let {
backStack.add(it)
backStack.remove(loginRoute)
}
}

fun logout() {
isLoggedIn = false
backStack.removeAll { it is RequiresLogin }
// An overload of `rememberNavBackStack` that returns a subtype of `NavKey`.
// If you would like to see this included in the Nav3 library please upvote the following issue:
// https://issuetracker.google.com/issues/463382671
@Composable
fun <T : NavKey> rememberNavBackStack(vararg elements: T): NavBackStack<T> {
return rememberSerializable(
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
) {
NavBackStack(*elements)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.nav3recipes.conditional

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.NavBackStack

/**
* Manages application navigation with built-in support for conditional access.
*
* This class wraps a [NavBackStack] to intercept navigation events. If a user attempts to navigate
* to a [Route] that requires login (via [Route.requiresLogin]) but is not currently authenticated,
* this Navigator will:
* 1. Save the intended destination.
* 2. Redirect the user to the [loginRoute].
*
* @property backStack The underlying Navigation 3 back stack that holds the history of [Route]s.
* @property loginRoute The specific [Route] representing the Login screen.
* @property isLoggedInState A [MutableState] representing the source of truth for the user's
* authentication status.
*/
class Navigator(
private val backStack: NavBackStack<Route>,
private val loginRoute: Route,
isLoggedInState: MutableState<Boolean>,
) {

private var isLoggedIn by isLoggedInState

// The route that we will navigate to after successful login.
private var onLoginSuccessRoute: Route? = null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The onLoginSuccessRoute property is not persisted across process death. This breaks the conditional navigation flow if the app is killed while on the login screen, defeating the purpose of this refactor.

To fix this, this state should be hoisted to ConditionalActivity and stored using rememberSaveable. The Navigator would then accept this state in its constructor.

In ConditionalActivity.kt:

val onLoginSuccessRoute = rememberSaveable { mutableStateOf<Route?>(null) }
val navigator = remember { // ...
    Navigator(
        // ...
        onLoginSuccessRouteState = onLoginSuccessRoute
    )
}

In Navigator.kt:

class Navigator(
    // ...
    onLoginSuccessRouteState: MutableState<Route?>,
) {
    // ...
    private var onLoginSuccessRoute by onLoginSuccessRouteState
    // ...
}

Since this change spans multiple files, I'm providing the explanation and examples here. Please apply these changes to make the navigation state fully saveable.


fun navigate(route: Route) {
if (route.requiresLogin && !isLoggedIn) {
// Store the intended destination and redirect to login
onLoginSuccessRoute = route
backStack.add(loginRoute)
} else {
backStack.add(route)
}

// If the user explicitly requested the login route, don't redirect them after login
if (route == loginRoute) {
onLoginSuccessRoute = null
}
}

fun goBack() = backStack.removeLastOrNull()

fun login() {
isLoggedIn = true
onLoginSuccessRoute?.let {
backStack.add(it)
backStack.remove(loginRoute)
}
}
Comment on lines +66 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The login() function has two issues:

  1. If a user navigates to the login screen manually (so onLoginSuccessRoute is null), after a successful login, they remain on the login screen. The login screen should be popped from the back stack.
  2. onLoginSuccessRoute is not cleared after being used. This could lead to unexpected redirection later if the user logs out and logs in again.
    fun login() {
        isLoggedIn = true
        onLoginSuccessRoute?.let { destination ->
            backStack.add(destination)
            backStack.remove(loginRoute)
            onLoginSuccessRoute = null
        } ?: run {
            // When login is successful, we should at least pop the login screen.
            if (backStack.lastOrNull() == loginRoute) {
                backStack.removeLast()
            }
        }
    }


fun logout() {
isLoggedIn = false
backStack.removeAll { it.requiresLogin }
}
}