When you have an authentication-enabled app, you must gate your Compose Navigation graph behind a “splash” or “gatekeeper” route that performs both:
Local state check (are we “logged in” locally?)
Server/session check (is the user’s token still valid?)
Because the Android 12+ native splash API is strictly for theming, you should:
Define a SplashRoute
as the first destination in your NavHost
.
In that composable, kick off your session‐validation logic (via a LaunchedEffect
) and then navigate onward.
@Composable
fun AppNavGraph(startDestination: String = Screen.Splash.route) {
NavHost(navController = navController, startDestination = startDestination) {
composable(Screen.Splash.route) { SplashRoute(navController) }
composable(Screen.Login.route) { LoginRoute(navController) }
composable(Screen.Home.route) { HomeRoute(navController) }
}
}
SplashRoute
Composable@Composable
fun SplashRoute(
navController: NavController,
viewModel: SplashViewModel = hiltViewModel()
) {
// Collect local-login flag and session status
val sessionState by viewModel.sessionState.collectAsState()
// Trigger a one‑time session check
LaunchedEffect(Unit) {
viewModel.checkSession()
}
// Simple UI while we wait
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
// React to the result as soon as it changes
when (sessionState) {
SessionState.Valid -> navController.replace(Screen.Home.route)
SessionState.Invalid -> navController.replace(Screen.Login.route)
SessionState.Loading -> { /* still showing spinner */ }
}
}
NavController extension
To avoid back‑stack issues, you can define:
fun NavController.replace(route: String) { navigate(route) { popUpTo(0) { inclusive = true } } }
SplashViewModel
@HiltViewModel
class SplashViewModel @Inject constructor(
private val sessionRepo: SessionRepository
) : ViewModel() {
private val _sessionState = MutableStateFlow(SessionState.Loading)
val sessionState: StateFlow<SessionState> = _sessionState
/** Or call this from init { … } if you prefer. */
fun checkSession() {
viewModelScope.launch {
// 1) Local check
if (!sessionRepo.isLoggedInLocally()) {
_sessionState.value = SessionState.Invalid
return@launch
}
// 2) Remote/session check
val ok = sessionRepo.verifyServerSession()
_sessionState.value = if (ok) SessionState.Valid else SessionState.Invalid
}
}
}
SessionRepository
Pseudocodeclass SessionRepository @Inject constructor(
private val dataStore: UserDataStore,
private val authApi: AuthApi
) {
/** True if we have a non-null token cached locally. */
suspend fun isLoggedInLocally(): Boolean =
dataStore.currentAuthToken() != null
/** Hits a “/me” or token‑refresh endpoint. */
suspend fun verifyServerSession(): Boolean {
return try {
authApi.getCurrentUser().isSuccessful
} catch (_: IOException) {
false
}
}
}
Single source of truth: All session logic lives in the ViewModel/Repository, not in your UI.
Deterministic navigation: The splash route never shows your real content until you’ve confirmed auth.
Seamless UX: User sees a spinner only while we’re verifying; they go immediately to Login or Home.
Feel free to refine the API endpoints (e.g., refresh token on 401) or to prefetch user preferences after you land on Home, but this gatekeeper pattern is the industry standard.