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
2 changes: 2 additions & 0 deletions template-compose/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kover)
Expand Down Expand Up @@ -142,6 +143,7 @@ dependencies {
ksp(libs.hilt.compiler)

implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.serialization.core)

implementation(libs.timber)
debugImplementation(libs.chucker)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
package co.nimblehq.template.compose.di.modules.main

import co.nimblehq.template.compose.navigation.EntryProviderInstaller
import co.nimblehq.template.compose.navigation.Navigator
import co.nimblehq.template.compose.navigation.NavigatorImpl
import co.nimblehq.template.compose.navigation.entries.Home
import co.nimblehq.template.compose.navigation.entries.homeScreenEntry
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.components.ActivityRetainedComponent
import dagger.hilt.android.scopes.ActivityRetainedScoped
import dagger.multibindings.IntoSet

@Module
@InstallIn(ActivityComponent::class)
class MainActivityModule
@InstallIn(ActivityRetainedComponent::class)
object MainActivityModule {

@Provides
@ActivityRetainedScoped
fun provideNavigator(): Navigator = NavigatorImpl(startDestination = Home)
Comment on lines +16 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the key files mentioned
find . -name "NavigatorImpl.kt" -o -name "AppNavigation.kt" -o -name "MainActivityModule.kt" | head -20

Repository: nimblehq/android-templates

Length of output: 671


🏁 Script executed:

# Search for the actual files in the template-compose project
fd -t f "NavigatorImpl.kt|AppNavigation.kt|MainActivityModule.kt" template-compose/

Repository: nimblehq/android-templates

Length of output: 355


🌐 Web query:

Hilt ActivityRetainedComponent lifecycle process death configuration changes

💡 Result:

ActivityRetainedComponent in Hilt: configuration change vs. process death

What it is

ActivityRetainedComponent is a Hilt component whose lifetime matches a configuration-surviving Activity (i.e., the logical Activity across rotations). It’s the backing scope for @ActivityRetainedScoped. [1], [2]

Configuration changes (rotation, locale, UI mode, etc.)

  • The component is created the first time the Activity is created (first Activity.onCreate() in that logical Activity lifetime). [1], [3]
  • It survives configuration changes, so a new Activity instance after rotation will reuse the same ActivityRetainedComponent instance. [2], [3]
  • It’s destroyed only when the Activity is actually finished, i.e., at the last Activity.onDestroy() (not the intermediate onDestroy() triggered by rotation). [1], [3]

Process death (app process killed, then later recreated)

  • ActivityRetainedComponent is in-memory only. If the OS kills your app process, the component is gone.
  • When the app is recreated after process death, Hilt builds a brand-new component graph, including a new ActivityRetainedComponent. (Nothing “retained” survives because the prior process memory is wiped.) This matches the general rule that ViewModel survives configuration changes but does not survive process death unless you persist/restore state separately. [4], [5]

Practical implication

  • Use @ActivityRetainedScoped for objects you want to reuse across rotations (common examples: “repo-like” instances tied to a screen flow).
  • For process-death survival, you must persist/restore state (e.g., SavedStateHandle for small UI state, or database/files for durable data); don’t rely on retained scopes for that. [5]

Sources: Hilt component lifetimes and note about creation/destruction across config changes [1], [3]; definition of ActivityRetainedComponent as configuration-surviving [2]; ViewModel/process-death + state restoration guidance (SavedStateHandle) [4], [5].


🌐 Web query:

Android Navigation 3 save state process death back stack persistence

💡 Result:

To survive process death with Jetpack Navigation 3, you must persist your back stack (because Nav3 makes the back stack app-owned). The intended path is:

  1. Use a saveable back stack primitive

    • NavBackStack is @Serializable, so it can be saved/restored via saved-state automatically when used with the provided “remember + serialization” helpers. This is specifically called out as enabling restore across process death + config change. [1]
    • The official Nav3 guidance is to create navigation state in a way that’s saveable (i.e., stored in Android saved state) rather than keeping it only in memory. [2][3]
  2. Ensure everything on the back stack is serializable

    • Because the back stack is serialized, your destination “keys” (routes/entries) must be serializable (typically via kotlinx.serialization). [1][3]
  3. If you keep per-destination state in ViewModels, scope it to the back stack entry

    • Nav3 provides an add-on (androidx.lifecycle:lifecycle-viewmodel-navigation3) with a NavEntryDecorator to scope/clear ViewModel state only while that NavEntry remains on the back stack, and clear it when popped—while still allowing restoration after process recreation when backed by saved state. [2]

Key takeaway: in Nav3, back stack persistence across process death happens by serializing NavBackStack into saved state (rather than NavController doing it implicitly like Nav2). [1][2]

Sources:
[1] AndroidX Navigation3 release notes (NavBackStack serializable; process-death restore)
[2] Android Developers: “Save and manage navigation state” (Navigation 3)
[3] Android Developers: Navigation 3 recipes showing saveable back stack patterns


🏁 Script executed:

cat -n template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/NavigatorImpl.kt

Repository: nimblehq/android-templates

Length of output: 1128


🏁 Script executed:

cat -n template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/AppNavigation.kt

Repository: nimblehq/android-templates

Length of output: 2644


🏁 Script executed:

cat -n template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt

Repository: nimblehq/android-templates

Length of output: 1231


This navigator loses the back stack after process death.

provideNavigator() binds Navigator to NavigatorImpl, which stores the back stack only in memory using SnapshotStateList. ActivityRetainedComponent survives configuration changes but not process death. When the app is killed and recreated, a new NavigatorImpl is instantiated with only Home as the initial entry. Navigation 3 requires back stacks to be persisted explicitly (via serializable NavBackStack and saved state) to survive process death. The decorators in AppNavigation (lines 32–33) handle per-entry state but do not persist the stack structure itself. Either persist the back stack keys via saved state or switch to Nav3's built-in serializable back stack primitives.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt`
around lines 16 - 21, The current provideNavigator binds Navigator to an
in-memory NavigatorImpl (uses SnapshotStateList) under ActivityRetainedComponent
so the back stack is lost on process death; update
provideNavigator/NavigatorImpl to persist the stack keys (not just per-entry
state handled by AppNavigation) by either (A) injecting a
SavedStateHandle/SavedStateRegistryOwner into NavigatorImpl and saving/restoring
the list of route/entry keys to saved state (serialize keys, restore into
SnapshotStateList on construction), or (B) replace the custom stack with Nav3’s
serializable back stack primitives (NavBackStack / savedState via NavController)
so the stack structure is saved across process death; adjust the Hilt provider
(provideNavigator) to accept the saved-state dependency or move the provider to
a component that can access SavedStateRegistry and ensure NavigatorImpl exposes
restore/save hooks called from onCreate/onSaveInstanceState.


@IntoSet
@Provides
fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller = {
homeScreenEntry(navigator)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package co.nimblehq.template.compose.extensions

import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge

fun ComponentActivity.setEdgeToEdgeConfig() {
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Force the 3-button navigation bar to be transparent
// See: https://developer.android.com/develop/ui/views/layout/edge-to-edge#create-transparent
window.isNavigationBarContrastEnforced = false
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package co.nimblehq.template.compose.navigation

import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import co.nimblehq.template.compose.util.LocalResultEventBus
import co.nimblehq.template.compose.util.ResultEventBus
import kotlinx.collections.immutable.ImmutableSet

private const val TWEEN_DURATION_IN_MILLIS = 500

@Composable
fun AppNavigation(
navigator: Navigator,
entryProviderScopes: ImmutableSet<EntryProviderInstaller>,
) {
val eventBus = remember { ResultEventBus() }
CompositionLocalProvider(LocalResultEventBus.provides(eventBus)) {
NavDisplay(
backStack = navigator.backStack,
onBack = { navigator.goBack() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entryProviderScopes.forEach { builder -> this.builder() }
},
transitionSpec = { horizontalSlideTransition(isPop = false) },
popTransitionSpec = { horizontalSlideTransition(isPop = true) },
predictivePopTransitionSpec = { horizontalSlideTransition(isPop = true) }
)
}
}

private fun horizontalSlideTransition(isPop: Boolean): ContentTransform =
slideInHorizontally(
initialOffsetX = { if (isPop) -it else it },
animationSpec = tween(TWEEN_DURATION_IN_MILLIS)
) togetherWith slideOutHorizontally(
targetOffsetX = { if (isPop) it else -it },
animationSpec = tween(TWEEN_DURATION_IN_MILLIS)
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package co.nimblehq.template.compose.navigation

import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation3.runtime.EntryProviderScope
import kotlin.reflect.KClass

typealias EntryProviderInstaller = EntryProviderScope<Any>.() -> Unit

interface Navigator {

val backStack: SnapshotStateList<Any>

fun goTo(destination: Any)

fun goBack()

fun goBackToLast(destinationClass: KClass<*>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package co.nimblehq.template.compose.navigation

import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlin.reflect.KClass

@ActivityRetainedScoped
class NavigatorImpl(startDestination: Any) : Navigator {
override val backStack: SnapshotStateList<Any> = mutableStateListOf(startDestination)

override fun goTo(destination: Any) {
backStack.add(destination)
}

override fun goBack() {
backStack.removeLastOrNull()
}

override fun goBackToLast(destinationClass: KClass<*>) {
val index = backStack.indexOfLast {
destinationClass.isInstance(it)
}

if (index in backStack.indices) {
backStack.removeRange(index + 1, backStack.size)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@file:Suppress("MatchingDeclarationName")

package co.nimblehq.template.compose.navigation.entries

import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import co.nimblehq.template.compose.navigation.Navigator
import co.nimblehq.template.compose.ui.screens.main.home.HomeScreen

/**
* **Template note:** Annotate this NavKey with `@Serializable` if you need to reference
* its serializer in a [DeepLinkPattern] (e.g. `DeepLinkPattern(Home.serializer(), uri)`).
*/
data object Home : NavKey

fun EntryProviderScope<Any>.homeScreenEntry(navigator: Navigator) {
entry<Home> {
HomeScreen(viewModel = hiltViewModel(), navigator = navigator)
}
}

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ abstract class BaseViewModel : ViewModel() {
protected val _error = MutableSharedFlow<Throwable>()
val error = _error.asSharedFlow()

protected val _navigator = MutableSharedFlow<BaseDestination>()
protected val _navigator = MutableSharedFlow<Any>()
val navigator = _navigator.asSharedFlow()

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,78 @@
package co.nimblehq.template.compose.ui.screens

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.navigation.compose.rememberNavController
import co.nimblehq.template.compose.ui.AppNavGraph
import co.nimblehq.template.compose.extensions.setEdgeToEdgeConfig
import co.nimblehq.template.compose.navigation.AppNavigation
import co.nimblehq.template.compose.navigation.EntryProviderInstaller
import co.nimblehq.template.compose.navigation.Navigator
import co.nimblehq.template.compose.ui.theme.ComposeTheme
import co.nimblehq.template.compose.util.DeepLinkMatcher
import co.nimblehq.template.compose.util.DeepLinkPattern
import co.nimblehq.template.compose.util.DeepLinkRequest
import co.nimblehq.template.compose.util.KeyDecoder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.toImmutableSet
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

@Inject
lateinit var navigator: Navigator

@Inject
lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller>

/**
* List of deep link patterns supported by the app.
*
* **Template note:** Navigation 3 does not natively support deep links. Add [DeepLinkPattern]
* instances here for each deep link your app should handle.
*
* Example:
* ```
* DeepLinkPattern(Home.serializer(), Uri.parse("https://example.com/home"))
* ```
*/
internal val deepLinkPatterns: List<DeepLinkPattern<out Any>> = listOf()
Comment thread
eeeeaa marked this conversation as resolved.

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setEdgeToEdgeConfig()
handleNewIntent(intent)
setContent {
ComposeTheme {
AppNavGraph(navController = rememberNavController())
AppNavigation(
navigator = navigator,
entryProviderScopes = entryProviderScopes.toImmutableSet()
)
}
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleNewIntent(intent)
}

private fun handleNewIntent(intent: Intent) {
val uri = intent.data ?: return
val deepLinkNavKey = resolveDeepLinkNavKey(uri)
intent.data = null
if (deepLinkNavKey != null) navigator.goTo(deepLinkNavKey)
}

private fun resolveDeepLinkNavKey(uri: Uri): Any? {
val request = DeepLinkRequest(uri)
val match = deepLinkPatterns.firstNotNullOfOrNull { pattern ->
DeepLinkMatcher(request, pattern).match()
} ?: return null
return try {
KeyDecoder(match.args).decodeSerializableValue(match.serializer)
} catch (_: Exception) { null }
}
}

This file was deleted.

Loading
Loading