Skip to content

[#617][Part 1/3] Migrate to navigation 3 - Template compose project#630

Open
eeeeaa wants to merge 4 commits intodevelopfrom
chore/#617-migrate-to-navigation-3-template-compose
Open

[#617][Part 1/3] Migrate to navigation 3 - Template compose project#630
eeeeaa wants to merge 4 commits intodevelopfrom
chore/#617-migrate-to-navigation-3-template-compose

Conversation

@eeeeaa
Copy link
Copy Markdown

@eeeeaa eeeeaa commented Feb 27, 2026

#617

What happened 👀

Note

Split migration into 3 Parts
Part 1 - migrate template compose
Part 2 - migrate sample compose
Part 3 - refactor sample compose examples

  • Add navigation 3 dependencies
  • Update navigation route to use NavKeys
  • Create Navigator class to hold and handle navigation state to replace NavController
  • Migrate from hilt-navigation-compose to hilt-lifecycle-viewmodel-compose for nav3
  • Add custom slide transitions for navigation in MainActivity
  • Remove nav2 destinations and related classes/dependencies
  • Add custom deep link implementation based on POC
  • Update tests and add FakeNavigator

Insight 📝

Reference:

Proof Of Work 📹

App launch successfully and behavior is the same

Screenshot 2569-03-10 at 16 16 53

Summary by CodeRabbit

  • New Features

    • New navigation system with navigator contract, app navigation host, animated horizontal transitions, and deep-link resolution
    • In-app result/event bus and a composable ResultEffect for inter-screen communication
    • Edge-to-edge layout support on modern Android
  • Refactor

    • Migration to the new navigation architecture and updated lifecycle-scoped components
  • Chores

    • Platform & library upgrades, including Kotlin serialization support
  • Tests

    • Navigation tests updated to use a fake navigator utility
  • Bug Fixes

    • NoConnectivityException converted to an instantiable exception class

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces the BaseDestination/NavHost approach with a Navigator/back-stack system, adds Navigation3 and Kotlinx serialization, introduces deep-link parsing/decoding utilities, an in-composition ResultEventBus, DI changes supplying Navigator, MainActivity deep-link handling and animations, and related test and dependency updates.

Changes

Cohort / File(s) Summary
Build / Catalog
template-compose/app/build.gradle.kts, template-compose/gradle/libs.versions.toml, template-compose/gradle/wrapper/gradle-wrapper.properties
Added Kotlin serialization plugin and kotlinx-serialization-core dependency; bumped Compose BOM, lifecycle, Hilt, KSP, Gradle wrapper; replaced compose-navigation with navigation3 artifacts and updated bundles/plugins.
Navigation Core & UI Host
template-compose/app/src/main/java/.../navigation/Navigator.kt, .../NavigatorImpl.kt, .../AppNavigation.kt
Added Navigator interface and NavigatorImpl (backStack + goTo/goBack/goBackToLast) plus AppNavigation composable wiring navigator, entry providers, ResultEventBus, and slide animations.
Navigation Entries
template-compose/app/src/main/java/.../navigation/entries/HomeScreenNavEntry.kt
Added NavKey Home and EntryProviderScope extension to register HomeScreen using the injected Navigator.
DI / Activity
template-compose/app/src/main/java/.../di/modules/main/MainActivityModule.kt, template-compose/app/src/main/java/.../ui/screens/MainActivity.kt
Module converted to object and installed in ActivityRetainedComponent; provides Navigator (ActivityRetainedScoped) and a set of EntryProviderInstaller; MainActivity now injects navigator and entryProviderScopes, handles deep-links (onNewIntent) and uses AppNavigation.
Deep-link & Decoding Utilities
template-compose/app/src/main/java/.../util/DeepLinkPattern.kt, .../DeepLinkRequest.kt, .../DeepLinkMatcher.kt, .../KeyDecoder.kt
New internal utilities to represent deep-link patterns, parse requests, match typed args, and decode argument maps into typed objects for navigation.
Result/Event Bus & Helpers
template-compose/app/src/main/java/.../util/ResultEventBus.kt, .../ResultEffect.kt
Added composition-scoped ResultEventBus (channel-backed flows) and ResultEffect composable to send/collect inter-screen results.
UI / Screens & ViewModel
template-compose/app/src/main/java/.../ui/screens/main/home/HomeScreen.kt, template-compose/app/src/main/java/.../ui/base/BaseViewModel.kt, template-compose/app/src/main/java/.../extensions/ComponentActivityExt.kt
HomeScreen now takes Navigator; BaseViewModel navigator flow type relaxed to Any; added ComponentActivity.setEdgeToEdgeConfig() extension; updated Hilt viewModel import usage.
Removed Legacy Navigation & Helpers
template-compose/app/src/main/java/.../ui/AppDestination.kt, .../ui/AppNavGraph.kt, .../ui/base/BaseDestination.kt, .../ui/screens/main/MainDestination.kt, .../ui/screens/main/MainNavGraph.kt, .../extensions/SavedStateHandleExt.kt
Removed legacy BaseDestination-based navigation types, NavGraph files, and SavedStateHandle.getThenRemove extension.
Tests / Test Utilities
template-compose/app/src/test/java/.../ui/screens/FakeNavigator.kt, .../HomeScreenTest.kt
Added FakeNavigator test double and updated HomeScreenTest to use FakeNavigator for navigation assertions.
Domain & Data Exceptions
template-compose/domain/src/main/.../exceptions/Exceptions.kt, template-compose/data/src/main/.../ResponseMapping.kt, template-compose/data/src/test/.../ResponseMappingTest.kt
Converted NoConnectivityException from object to class; updated mapping to construct instances and adjusted tests to assert instance type.
Minor build file edit
template-compose/app/build.gradle.kts
Added implementation(libs.kotlinx.serialization.core) and applied Kotlin serialization plugin.

Sequence Diagram(s)

sequenceDiagram
    participant Activity as MainActivity
    participant Matcher as DeepLinkMatcher
    participant Decoder as KeyDecoder
    participant Navigator as Navigator
    participant BackStack as SnapshotStateList
    Activity->>Matcher: Matcher.match(DeepLinkRequest, DeepLinkPattern)
    Matcher-->>Activity: DeepLinkMatchResult(serializer, args)
    Activity->>Decoder: KeyDecoder.decode(args, serializer)
    Decoder-->>Activity: typedDestination
    Activity->>Navigator: navigator.goTo(typedDestination)
    Navigator->>BackStack: append(destination)
Loading
sequenceDiagram
    participant Screen as HomeScreen
    participant ViewModel as HomeViewModel
    participant Navigator as Navigator
    participant BackStack as SnapshotStateList
    Screen->>ViewModel: user action -> emit navigation intent
    ViewModel->>Navigator: navigator.goTo(destination)
    Navigator->>BackStack: append(destination)
    BackStack-->>Screen: recomposition with new top destination
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • RkNaing
  • kaungkhantsoe
  • hoangnguyen92dn
  • AVI5HEK
  • sleepylee
  • win-than-htike
  • luongvo
  • manh-t

Poem

🐰 I hopped through stacks both old and new,

Patterns parsed and keys decoded too,
A navigator guides each curious trail,
Channels hum and carry every tale,
Tiny carrots mark the serialized view. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: migrating the template compose project to Navigation 3 as part 1 of a multi-part effort.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch chore/#617-migrate-to-navigation-3-template-compose
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can disable sequence diagrams in the walkthrough.

Disable the reviews.sequence_diagrams setting to disable sequence diagrams in the walkthrough.

@eeeeaa eeeeaa changed the base branch from chore/#617-migrate-to-navigation-3 to develop February 27, 2026 08:06
@eeeeaa eeeeaa changed the title [#617][Part 2] Migrate to navigation 3 - Template compose project [#617] Migrate to navigation 3 - Template compose project Feb 27, 2026
@eeeeaa eeeeaa force-pushed the chore/#617-migrate-to-navigation-3-template-compose branch from c8e2d19 to d43951a Compare February 27, 2026 08:08
@eeeeaa eeeeaa changed the title [#617] Migrate to navigation 3 - Template compose project [#617][Part 1] Migrate to navigation 3 - Template compose project Feb 27, 2026
@eeeeaa eeeeaa changed the title [#617][Part 1] Migrate to navigation 3 - Template compose project [#617][Part 1/3] Migrate to navigation 3 - Template compose project Feb 27, 2026
@hoangnguyen92dn hoangnguyen92dn added this to the 0.34.0 milestone Mar 2, 2026
@eeeeaa eeeeaa force-pushed the chore/#617-migrate-to-navigation-3-template-compose branch from d43951a to b9181b3 Compare March 6, 2026 08:00
@eeeeaa eeeeaa temporarily deployed to template-compose March 6, 2026 08:00 — with GitHub Actions Inactive
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 6, 2026

14 Warnings
⚠️ Big PR
⚠️ Uh oh! AppNavigation.kt is under 95% coverage!
⚠️ Uh oh! BaseViewModel.kt is under 95% coverage!
⚠️ Uh oh! DeepLinkMatcher.kt is under 95% coverage!
⚠️ Uh oh! DeepLinkPattern.kt is under 95% coverage!
⚠️ Uh oh! DeepLinkRequest.kt is under 95% coverage!
⚠️ Uh oh! HomeScreen.kt is under 95% coverage!
⚠️ Uh oh! KeyDecoder.kt is under 95% coverage!
⚠️ Uh oh! NavigatorImpl.kt is under 95% coverage!
⚠️ Uh oh! ResultEffect.kt is under 95% coverage!
⚠️ Uh oh! ResultEventBus.kt is under 95% coverage!
⚠️ Uh oh! Your project is under 80% coverage!
⚠️ template-compose/gradle/libs.versions.toml#L35 - A newer version of androidx.security:security-crypto than 1.0.0 is available: 1.1.0
⚠️ template-compose/gradle/libs.versions.toml#L36 - A newer version of androidx.test:core-ktx than 1.6.1 is available: 1.7.0

Kover report for template-compose:

🧛 Template - Compose Unit Tests Code Coverage: 55.26%

Coverage of Modified Files:

File Coverage
AppNavigation.kt 90.91%
BaseViewModel.kt 85.71%
ComponentActivityExt.kt 100.00%
DeepLinkMatcher.kt 0.00%
DeepLinkPattern.kt 0.00%
DeepLinkRequest.kt 0.00%
Exceptions.kt 100.00%
HomeScreen.kt 92.00%
HomeScreenNavEntry.kt 100.00%
KeyDecoder.kt 0.00%
NavigatorImpl.kt 25.00%
ResponseMapping.kt 95.00%
ResultEffect.kt 0.00%
ResultEventBus.kt 22.22%

Modified Files Not Found In Coverage Report:

FakeNavigator.kt
HomeScreenTest.kt
MainActivity.kt
MainActivityModule.kt
Navigator.kt
ResponseMappingTest.kt
build.gradle.kts
gradle-wrapper.properties
libs.versions.toml

Codebase cunningly covered by count Shroud 🧛

Generated by 🚫 Danger

@eeeeaa eeeeaa temporarily deployed to template-compose March 6, 2026 09:13 — with GitHub Actions Inactive
@eeeeaa eeeeaa force-pushed the chore/#617-migrate-to-navigation-3-template-compose branch from b9181b3 to 4a9adbc Compare March 6, 2026 09:43
@eeeeaa eeeeaa temporarily deployed to template-compose March 6, 2026 09:43 — with GitHub Actions Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (6)
template-compose/app/build.gradle.kts (1)

146-146: Consider depending on kotlinx-serialization-core unless template-compose/app uses Json directly.

The deeplink helpers in template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt and KeyDecoder.kt only use core serialization types (KSerializer, SerialDescriptor, AbstractDecoder). No usage of Json, encodeToString, or decodeFromString exists in the app module, so the narrower kotlinx-serialization-core dependency is sufficient.

♻️ Suggested change
-    implementation(libs.kotlinx.serialization.json)
+    implementation(libs.kotlinx.serialization.core)

Also add a catalog alias:

kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@template-compose/app/build.gradle.kts` at line 146, The project currently
adds implementation(libs.kotlinx.serialization.json) but the app module only
uses core serialization types (see DeepLinkPattern.kt and KeyDecoder.kt which
reference KSerializer, SerialDescriptor, AbstractDecoder), so replace the json
artifact with the core artifact: update the dependency in build.gradle.kts to
use the kotlinx-serialization-core catalog alias (i.e., change the
implementation(...) entry referencing kotlinx.serialization.json to the core
alias) and add the corresponding catalog alias entry kotlinx-serialization-core
= { group="org.jetbrains.kotlinx", name="kotlinx-serialization-core",
version.ref="kotlinxSerialization" } to the version catalog.
template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt (1)

21-25: Null-to-empty-string substitution may cause unintended parse failures.

When a query parameter is present but has no value (e.g., ?foo or ?foo=), getQueryParameter returns null or empty string. The current code maps both to "", which will then fail parsing for non-string types in DeepLinkMatcher (e.g., "".toInt() throws).

While matchQueryArgs catches this and returns false, the semantics are unclear: is "present but null" different from "present but empty"?

Consider excluding null values from the map so they aren't attempted to parse:

♻️ Proposed refinement
     val queries = buildMap {
         uri.queryParameterNames.forEach { argName ->
-            this[argName] = uri.getQueryParameter(argName) ?: ""
+            uri.getQueryParameter(argName)?.let { value ->
+                this[argName] = value
+            }
         }
     }

This way, only query parameters with actual values are included in the map, and DeepLinkMatcher won't attempt to parse absent values.

🤖 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/util/DeepLinkRequest.kt`
around lines 21 - 25, The current buildMap in DeepLinkRequest.kt unconditionally
substitutes null query parameter values to "" which causes parsing errors
downstream in DeepLinkMatcher; change the map population to only insert entries
when uri.getQueryParameter(argName) returns a non-null value (i.e., skip nulls)
so that "present but no value" parameters are excluded from queries and
DeepLinkMatcher.matchQueryArgs won't attempt to parse empty placeholders.
template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt (1)

49-53: Potential inconsistency between contains check and null handling.

Line 49 uses arguments.contains(currentName) which returns true even if the value is null. However, if a null value is present, decodeValue() will throw at Line 63. The serialization flow should call decodeNotNullMark() first, but this contract depends on the serializer implementation.

Consider aligning the check to only process non-null entries:

♻️ Safer alternative
-            if (arguments.contains(currentName)) {
+            if (arguments[currentName] != null) {

This ensures only non-null arguments trigger decoding, making the behavior consistent with decodeNotNullMark().

🤖 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/util/KeyDecoder.kt`
around lines 49 - 53, The check arguments.contains(currentName) in KeyDecoder
(the block that sets elementIndex/elementName and returns elementIndex) can
accept null values which later causes decodeValue() to throw; change the logic
to only treat an entry as present when it is non-null — e.g., verify the
argument value for currentName is not null (or call the decoder's
decodeNotNullMark()/equivalent null-check) before assigning
elementIndex/elementName and returning; update the code paths around
decodeValue() (the method invoking decodeNotNullMark() and decodeValue()) so
presence detection and null handling are consistent and avoid attempting to
decode null entries.
template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt (1)

8-9: Make the fake start from the same initial destination as production.

MainActivityModule provides NavigatorImpl(startDestination = Home), but this fake starts with an empty stack. That lets tests pass against states the app can never reach and can hide back-stack regressions. Seed it with the real start destination, or accept one via constructor so tests can mirror production.

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

In
`@template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt`
around lines 8 - 9, FakeNavigator currently initializes backStack empty which
diverges from production where MainActivityModule constructs
NavigatorImpl(startDestination = Home); change FakeNavigator to accept a start
destination (e.g., a parameter like startDestination: Any) or seed its backStack
with the production start (Home) so tests mirror real app state; update the
FakeNavigator class constructor and its override val backStack:
SnapshotStateList<Any> = mutableStateListOf(startDestination) (or push Home when
no arg) and adjust tests to pass the same startDestination used by
NavigatorImpl.
template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt (2)

65-65: Consider making channelMap private.

Exposing the mutable map as public val breaks encapsulation and allows external code to directly manipulate the internal state, potentially bypassing the intended API and causing inconsistent behavior.

♻️ Proposed fix
-    val channelMap: MutableMap<String, Channel<Any?>> = mutableMapOf()
+    private val channelMap: MutableMap<String, Channel<Any?>> = mutableMapOf()
🤖 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/util/ResultEventBus.kt`
at line 65, The public mutable map channelMap on ResultEventBus breaks
encapsulation; make channelMap private (e.g., private val channelMap:
MutableMap<String, Channel<Any?>>) and expose only the necessary API from
ResultEventBus (public methods such as register/obtain/closeChannel or a
read-only view like getChannel(key): Channel<Any?>?) so external code cannot
mutate the internal map directly; update all usages of channelMap inside the
class to the new private property and call the public methods instead.

86-89: Potential race between close() and remove() in concurrent scenarios.

If another coroutine calls sendResult or getResultFlow for the same key between close() and remove(), it could interact with a closed channel or recreate it unexpectedly. If thread-safety is a concern, consider using synchronized access or ConcurrentHashMap.remove().

🤖 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/util/ResultEventBus.kt`
around lines 86 - 89, The current removeResult closes the channel then removes
it, which can race with sendResult/getResultFlow creating a new channel between
those calls; change removeResult to atomically remove first and then close the
removed channel (e.g. val ch = channelMap.remove(resultKey); ch?.close()) and
ensure channelMap is a thread-safe map (e.g. ConcurrentHashMap) so operations
from sendResult/getResultFlow and removeResult are safe; reference removeResult,
channelMap, sendResult and getResultFlow when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt`:
- Around line 101-117: In handleNewIntent, wrap the deep-link deserialization
and navigation in a try-catch: surround the
KeyDecoder(match.args).decodeSerializableValue(match.serializer) and subsequent
navigator.goTo(deepLinkNavKey) call with a try block and catch
serialization/deserialization exceptions (e.g., Exception or a more specific
serialization exception), and in the catch clear intent.data (set to null) and
skip navigation so a bad/malformed intent does not crash the activity; keep the
existing logic to clear intent.data after successful navigation as well.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt`:
- Around line 37-42: The catch logging in DeepLinkMatcher (inside the
candidateSegment.typeParser invocation) currently logs the raw requestedSegment;
update the error logging to avoid printing raw deep-link values and instead log
safe identifiers (e.g., the argument name or pattern from candidateSegment) and
context (e.g., route pattern) — do the same for the query parsing block around
the query.value handling (lines referencing query.value) so no raw URI values or
PII are emitted; locate the code in DeepLinkMatcher.kt where
candidateSegment.typeParser is invoked and where query.value is logged and
replace the message to include only argument names/patterns and the exception
details, not the raw input.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt`:
- Around line 107-113: The Boolean and Char parsers in DeepLinkPattern.kt use
lenient functions (String::toBoolean and a lambda using it.first()) that either
silently coerce values or throw the wrong exception; replace the
PrimitiveKind.BOOLEAN and PrimitiveKind.CHAR branches with strict validators
that explicitly check the input string: for BOOLEAN only accept "true" or
"false" (return corresponding Boolean or throw IllegalArgumentException on any
other value) and for CHAR ensure the string length is exactly 1 (return the
single Char or throw IllegalArgumentException otherwise) so DeepLinkMatcher can
catch malformed deep links properly.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt`:
- Around line 40-42: LaunchedEffect is depending on
resultEventBus.channelMap[resultKey] which is a plain MutableMap entry and
getResultFlow(resultKey) can return null until sendResult creates the channel,
so the effect may never restart and miss results; fix by ensuring the
channel/flow is created before collecting—either call a new or existing eager
factory on ResultEventBus (e.g., add/invoke ensureChannelOrGetFlow(resultKey) or
change getResultFlow to create the channel if absent) so that
LaunchedEffect(resultKey, ...) always observes a non-null flow, then collect
from resultEventBus.getResultFlow<T>(resultKey) safely (or use the returned
non-null flow) instead of relying on a map entry change; reference symbols:
LaunchedEffect, resultKey, resultEventBus.channelMap, getResultFlow, sendResult,
channelMap.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt`:
- Around line 76-81: sendResult has a race between channelMap.contains +
creation and uses trySend with BufferOverflow.SUSPEND which is contradictory;
fix by making channelMap access atomic (use channelMap.getOrPut(resultKey) or
switch channelMap to a thread-safe ConcurrentHashMap) to ensure only one Channel
is created for a key, and replace trySend(result) with a suspending send (e.g.,
launch/coroutineScope { channel.send(result) }) if you want suspension on full
buffer, or change the Channel creation policy to a non-suspending overflow
(DROP_OLDEST/DROP_LATEST) if silent drops are acceptable; update the code paths
referencing sendResult and channelMap to match the chosen approach (sendResult,
channelMap, Channel).

In
`@template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt`:
- Line 37: The test currently uses value equality ("it shouldBe
NoConnectivityException()") which checks reference/equality rather than type;
update the assertion in ResponseMappingTest to a type-based check by replacing
the shouldBe NoConnectivityException() call with a Kotest type assertion such as
shouldBeInstanceOf<NoConnectivityException>() (or
shouldBeInstanceOf(NoConnectivityException::class)) so the test verifies the
exception type rather than instance equality.
- Line 27: The test uses reference equality against a class instance
(NoConnectivityException()) which will fail; change the assertion to a
type-based check by replacing the equality assertion with a Kotest type matcher
(use shouldBeInstanceOf for the exception under test) and add the import
io.kotest.matchers.types.shouldBeInstanceOf so the assertion verifies the value
is an instance of NoConnectivityException rather than comparing object
references.

---

Nitpick comments:
In `@template-compose/app/build.gradle.kts`:
- Line 146: The project currently adds
implementation(libs.kotlinx.serialization.json) but the app module only uses
core serialization types (see DeepLinkPattern.kt and KeyDecoder.kt which
reference KSerializer, SerialDescriptor, AbstractDecoder), so replace the json
artifact with the core artifact: update the dependency in build.gradle.kts to
use the kotlinx-serialization-core catalog alias (i.e., change the
implementation(...) entry referencing kotlinx.serialization.json to the core
alias) and add the corresponding catalog alias entry kotlinx-serialization-core
= { group="org.jetbrains.kotlinx", name="kotlinx-serialization-core",
version.ref="kotlinxSerialization" } to the version catalog.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt`:
- Around line 21-25: The current buildMap in DeepLinkRequest.kt unconditionally
substitutes null query parameter values to "" which causes parsing errors
downstream in DeepLinkMatcher; change the map population to only insert entries
when uri.getQueryParameter(argName) returns a non-null value (i.e., skip nulls)
so that "present but no value" parameters are excluded from queries and
DeepLinkMatcher.matchQueryArgs won't attempt to parse empty placeholders.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt`:
- Around line 49-53: The check arguments.contains(currentName) in KeyDecoder
(the block that sets elementIndex/elementName and returns elementIndex) can
accept null values which later causes decodeValue() to throw; change the logic
to only treat an entry as present when it is non-null — e.g., verify the
argument value for currentName is not null (or call the decoder's
decodeNotNullMark()/equivalent null-check) before assigning
elementIndex/elementName and returning; update the code paths around
decodeValue() (the method invoking decodeNotNullMark() and decodeValue()) so
presence detection and null handling are consistent and avoid attempting to
decode null entries.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt`:
- Line 65: The public mutable map channelMap on ResultEventBus breaks
encapsulation; make channelMap private (e.g., private val channelMap:
MutableMap<String, Channel<Any?>>) and expose only the necessary API from
ResultEventBus (public methods such as register/obtain/closeChannel or a
read-only view like getChannel(key): Channel<Any?>?) so external code cannot
mutate the internal map directly; update all usages of channelMap inside the
class to the new private property and call the public methods instead.
- Around line 86-89: The current removeResult closes the channel then removes
it, which can race with sendResult/getResultFlow creating a new channel between
those calls; change removeResult to atomically remove first and then close the
removed channel (e.g. val ch = channelMap.remove(resultKey); ch?.close()) and
ensure channelMap is a thread-safe map (e.g. ConcurrentHashMap) so operations
from sendResult/getResultFlow and removeResult are safe; reference removeResult,
channelMap, sendResult and getResultFlow when making the change.

In
`@template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt`:
- Around line 8-9: FakeNavigator currently initializes backStack empty which
diverges from production where MainActivityModule constructs
NavigatorImpl(startDestination = Home); change FakeNavigator to accept a start
destination (e.g., a parameter like startDestination: Any) or seed its backStack
with the production start (Home) so tests mirror real app state; update the
FakeNavigator class constructor and its override val backStack:
SnapshotStateList<Any> = mutableStateListOf(startDestination) (or push Home when
no arg) and adjust tests to pass the same startDestination used by
NavigatorImpl.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1ff5e6f5-184f-4025-9079-6ea56d0e87eb

📥 Commits

Reviewing files that changed from the base of the PR and between adcd72d and 2407831.

📒 Files selected for processing (27)
  • template-compose/app/build.gradle.kts
  • template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/ComponentActivityExt.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/SavedStateHandleExt.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/Navigator.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/NavigatorImpl.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseViewModel.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt
  • template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt
  • template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreenTest.kt
  • template-compose/data/src/main/java/co/nimblehq/template/compose/data/extensions/ResponseMapping.kt
  • template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt
  • template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/exceptions/Exceptions.kt
  • template-compose/gradle/libs.versions.toml
  • template-compose/gradle/wrapper/gradle-wrapper.properties
💤 Files with no reviewable changes (6)
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/base/BaseDestination.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/extensions/SavedStateHandleExt.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppNavGraph.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/AppDestination.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainNavGraph.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/MainDestination.kt

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (6)
template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt (1)

67-67: Consider making channelMap private.

The channelMap property is currently public which exposes the internal implementation details of the event bus. This allows external code to directly manipulate the map, bypassing the provided API methods (sendResult, ensureChannelAndGetFlow, removeResult).

♻️ Proposed fix to encapsulate internal state
-    val channelMap = ConcurrentHashMap<String, Channel<Any?>>()
+    private val channelMap = ConcurrentHashMap<String, Channel<Any?>>()
🤖 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/util/ResultEventBus.kt`
at line 67, The channelMap field in ResultEventBus is public and exposes
internal state; make channelMap private to encapsulate the implementation and
prevent external mutation. Update the declaration of channelMap to be private
(retain type ConcurrentHashMap<String, Channel<Any?>>) and ensure existing
methods sendResult, ensureChannelAndGetFlow, and removeResult continue to access
it internally; run tests/compile to confirm no external usages rely on the
public map and replace any external usages with new API methods if found.
template-compose/gradle/libs.versions.toml (1)

68-69: Minor formatting: missing blank line before section comment.

For consistency with other sections in this file, consider adding a blank line between the kotlinx-serialization-core declaration and the # Log comment.

📝 Suggested formatting fix
 kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" }
+
 # Log
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@template-compose/gradle/libs.versions.toml` around lines 68 - 69, Add a blank
line between the kotlinx-serialization-core dependency declaration
(kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name =
"kotlinx-serialization-core", version.ref = "kotlinxSerialization" }) and the
following section comment (“# Log”) so the file matches the other section
formatting and keeps a clear separation between dependency entries and section
headers.
template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt (1)

1-7: Consider relocating to mirror source directory structure.

Per coding guidelines, test files should mirror source directory structure. Navigator is in navigation/, but FakeNavigator is in ui/screens/. Consider moving to test/.../navigation/FakeNavigator.kt for consistency.

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

In
`@template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt`
around lines 1 - 7, The FakeNavigator test utility is placed under ui/screens
which doesn't mirror the production package where Navigator lives (navigation);
move the FakeNavigator class file into the test package that parallels the
source navigation package (e.g., test/.../navigation/) so its package path
matches Navigator's structure; update the Kotlin package declaration inside the
FakeNavigator file to the new navigation test package and adjust any
imports/usages referencing FakeNavigator to the new package to ensure tests
compile (look for the FakeNavigator type and Navigator interface references).
template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt (1)

72-89: popTransitionSpec and predictivePopTransitionSpec are identical — consider extracting.

Both transition specs have the same implementation. Extract to a shared variable to reduce duplication.

♻️ Suggested refactor
+        val popSpec: NavDisplay.TransitionSpec = {
+            slideInHorizontally(
+                initialOffsetX = { -it },
+                animationSpec = tween(TWEEN_DURATION_IN_MILLIS)
+            ) togetherWith slideOutHorizontally(
+                targetOffsetX = { it },
+                animationSpec = tween(TWEEN_DURATION_IN_MILLIS)
+            )
+        }
         // ... in NavDisplay call:
-        popTransitionSpec = {
-            slideInHorizontally(
-                initialOffsetX = { -it },
-                animationSpec = tween(TWEEN_DURATION_IN_MILLIS)
-            ) togetherWith slideOutHorizontally(
-                targetOffsetX = { it },
-                animationSpec = tween(TWEEN_DURATION_IN_MILLIS)
-            )
-        },
-        predictivePopTransitionSpec = {
-            slideInHorizontally(
-                initialOffsetX = { -it },
-                animationSpec = tween(TWEEN_DURATION_IN_MILLIS)
-            ) togetherWith slideOutHorizontally(
-                targetOffsetX = { it },
-                animationSpec = tween(TWEEN_DURATION_IN_MILLIS)
-            )
-        }
+        popTransitionSpec = popSpec,
+        predictivePopTransitionSpec = popSpec
🤖 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/ui/screens/MainActivity.kt`
around lines 72 - 89, popTransitionSpec and predictivePopTransitionSpec in
MainActivity.kt duplicate the same slideInHorizontally/slideOutHorizontally
logic using TWEEN_DURATION_IN_MILLIS; extract the shared AnimationSpec into a
single val (e.g., horizontalPopTransitionSpec or POP_TRANSITION_SPEC) and
replace both popTransitionSpec and predictivePopTransitionSpec with that
variable to remove duplication and keep behavior identical. Ensure the new val
is scoped appropriately (top-level or inside the MainActivity composable) and
references TWEEN_DURATION_IN_MILLIS just like the originals.
template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt (1)

17-22: Consider handling URI comparison case-sensitivity.

The exact match check at line 20 compares request.uri == deepLinkPattern.uriPattern directly. If the incoming URI has different casing in the scheme or host, this may fail to match even when semantically equivalent.

🤖 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/util/DeepLinkMatcher.kt`
around lines 17 - 22, The exact-match check inside DeepLinkMatcher.match()
currently does a direct string comparison of request.uri and
deepLinkPattern.uriPattern, which is case-sensitive; update the exact-match
logic to perform a case-insensitive comparison for scheme and host (while
keeping path/authority/queries compared as appropriate) by parsing both strings
into URIs (e.g., java.net.URI) and comparing uri.scheme.lowercase() and
uri.host.lowercase() plus the normalized path/query components before returning
DeepLinkMatchResult(deepLinkPattern.serializer, mapOf()); adjust the condition
that presently checks request.uri == deepLinkPattern.uriPattern so semantically
equivalent URIs with different casing still match.
template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt (1)

82-88: Same potential crash for query parameter names.

The same issue applies here: getElementIndex(paramName) will throw if a query parameter in the URI pattern doesn't match any field in the serializer's descriptor.

🤖 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/util/DeepLinkPattern.kt`
around lines 82 - 88, The loop building queryValueParsers can throw when
uriPattern.queryParameterNames contains a name not present in the serializer
descriptor; guard against that by skipping unknown query names before calling
serializer.descriptor.getElementIndex and getElementDescriptor (e.g. check
paramName is in serializer.descriptor.elementNames or safely attempt
getElementIndex inside a try/catch), and only call getTypeParser and put an
entry into queryValueParsers for valid descriptor elements referenced by
getElementDescriptor; update the code around queryValueParsers, getElementIndex,
serializer.descriptor.getElementDescriptor, and getTypeParser accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt`:
- Line 42: deepLinkPatterns is initialized empty, so handleNewIntent will never
match deep links (firstNotNullOfOrNull returns null); either add a TODO comment
indicating deep link support is pending or populate deepLinkPatterns with actual
DeepLinkPattern instances (e.g., provide them via Hilt multibinding similar to
entryProviderScopes) and update usages in handleNewIntent to iterate those
patterns; specifically modify the deepLinkPatterns declaration and the code that
resolves patterns in handleNewIntent/firstNotNullOfOrNull to use the
injected/multibound list of DeepLinkPattern objects.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt`:
- Around line 64-69: During DeepLinkPattern construction, validate the result of
serializer.descriptor.getElementIndex(argName) before calling
getElementDescriptor: check that elementIndex >= 0 and if not throw/raise an
error that clearly names the missing argument (argName) and the serializable
type context (serializer.descriptor) so callers know which argument is absent;
update the block that currently calls getElementIndex -> getElementDescriptor ->
add(PathSegment(...)) to perform this guard and produce a descriptive error
instead of allowing getElementDescriptor to fail.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt`:
- Around line 80-81: Remove the unused helper method getResultFlow (inline fun
<reified T> getResultFlow(resultKey: String = T::class.toString()) =
channelMap[resultKey]?.receiveAsFlow()) from ResultEventBus; ensure callers use
the existing ensureChannelAndGetFlow API (used in ResultEffect.kt) which
guarantees a channel exists before returning the flow, and run project-wide
search to confirm no references remain before committing.

---

Nitpick comments:
In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt`:
- Around line 72-89: popTransitionSpec and predictivePopTransitionSpec in
MainActivity.kt duplicate the same slideInHorizontally/slideOutHorizontally
logic using TWEEN_DURATION_IN_MILLIS; extract the shared AnimationSpec into a
single val (e.g., horizontalPopTransitionSpec or POP_TRANSITION_SPEC) and
replace both popTransitionSpec and predictivePopTransitionSpec with that
variable to remove duplication and keep behavior identical. Ensure the new val
is scoped appropriately (top-level or inside the MainActivity composable) and
references TWEEN_DURATION_IN_MILLIS just like the originals.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt`:
- Around line 17-22: The exact-match check inside DeepLinkMatcher.match()
currently does a direct string comparison of request.uri and
deepLinkPattern.uriPattern, which is case-sensitive; update the exact-match
logic to perform a case-insensitive comparison for scheme and host (while
keeping path/authority/queries compared as appropriate) by parsing both strings
into URIs (e.g., java.net.URI) and comparing uri.scheme.lowercase() and
uri.host.lowercase() plus the normalized path/query components before returning
DeepLinkMatchResult(deepLinkPattern.serializer, mapOf()); adjust the condition
that presently checks request.uri == deepLinkPattern.uriPattern so semantically
equivalent URIs with different casing still match.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt`:
- Around line 82-88: The loop building queryValueParsers can throw when
uriPattern.queryParameterNames contains a name not present in the serializer
descriptor; guard against that by skipping unknown query names before calling
serializer.descriptor.getElementIndex and getElementDescriptor (e.g. check
paramName is in serializer.descriptor.elementNames or safely attempt
getElementIndex inside a try/catch), and only call getTypeParser and put an
entry into queryValueParsers for valid descriptor elements referenced by
getElementDescriptor; update the code around queryValueParsers, getElementIndex,
serializer.descriptor.getElementDescriptor, and getTypeParser accordingly.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt`:
- Line 67: The channelMap field in ResultEventBus is public and exposes internal
state; make channelMap private to encapsulate the implementation and prevent
external mutation. Update the declaration of channelMap to be private (retain
type ConcurrentHashMap<String, Channel<Any?>>) and ensure existing methods
sendResult, ensureChannelAndGetFlow, and removeResult continue to access it
internally; run tests/compile to confirm no external usages rely on the public
map and replace any external usages with new API methods if found.

In
`@template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt`:
- Around line 1-7: The FakeNavigator test utility is placed under ui/screens
which doesn't mirror the production package where Navigator lives (navigation);
move the FakeNavigator class file into the test package that parallels the
source navigation package (e.g., test/.../navigation/) so its package path
matches Navigator's structure; update the Kotlin package declaration inside the
FakeNavigator file to the new navigation test package and adjust any
imports/usages referencing FakeNavigator to the new package to ensure tests
compile (look for the FakeNavigator type and Navigator interface references).

In `@template-compose/gradle/libs.versions.toml`:
- Around line 68-69: Add a blank line between the kotlinx-serialization-core
dependency declaration (kotlinx-serialization-core = { group =
"org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref =
"kotlinxSerialization" }) and the following section comment (“# Log”) so the
file matches the other section formatting and keeps a clear separation between
dependency entries and section headers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08b81067-8fc5-4319-8b59-71e7bc27085a

📥 Commits

Reviewing files that changed from the base of the PR and between 2407831 and 46cd22d.

📒 Files selected for processing (11)
  • template-compose/app/build.gradle.kts
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt
  • template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt
  • template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt
  • template-compose/gradle/libs.versions.toml
🚧 Files skipped from review as they are similar to previous changes (3)
  • template-compose/app/build.gradle.kts
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt

@eeeeaa eeeeaa force-pushed the chore/#617-migrate-to-navigation-3-template-compose branch from 46cd22d to 82ed328 Compare March 11, 2026 10:16
@coderabbitai coderabbitai bot temporarily deployed to template-compose March 11, 2026 10:18 Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt (1)

68-71: ⚠️ Potential issue | 🟠 Major

Validate placeholder names before reading descriptor indices.

getElementIndex() can return an unknown-name sentinel when a path or query placeholder is not a field on T. Passing that value into getElementDescriptor() crashes DeepLinkPattern construction, so one typo in the URI template takes the app down instead of failing with a clear configuration error.

Suggested fix
-                val elementIndex = serializer.descriptor.getElementIndex(argName)
-                val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
+                val elementIndex = serializer.descriptor.getElementIndex(argName)
+                require(elementIndex >= 0) {
+                    "Unknown deep link argument '$argName' for ${serializer.descriptor.serialName}"
+                }
+                val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
                 // finally, add the arg name and its respective type parser to the map
                 add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind)))
@@
-            val elementIndex = serializer.descriptor.getElementIndex(paramName)
-            val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
+            val elementIndex = serializer.descriptor.getElementIndex(paramName)
+            require(elementIndex >= 0) {
+                "Unknown deep link query '$paramName' for ${serializer.descriptor.serialName}"
+            }
+            val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
             this[paramName] = getTypeParser(elementDescriptor.kind)
For kotlinx.serialization SerialDescriptor, what does getElementIndex(name) return when the name is missing, and is that return value valid to pass into getElementDescriptor(index)?

Also applies to: 87-89

🤖 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/util/DeepLinkPattern.kt`
around lines 68 - 71, DeepLinkPattern construction currently calls
serializer.descriptor.getElementIndex(argName) and immediately passes the result
into getElementDescriptor, which will crash if getElementIndex returns the
"unknown-name" sentinel; instead, after computing elementIndex (from
serializer.descriptor.getElementIndex(argName)), validate that it is not the
sentinel (e.g., negative/SerialDescriptor.UNKNOWN_NAME index) and if it is,
throw or return a clear configuration error mentioning the invalid placeholder
name and the DeepLinkPattern template; apply the same check for the other
occurrence around the code that handles query/path placeholders (the similar
block at lines 87-89) so typos in URI templates produce readable errors rather
than runtime crashes.
🧹 Nitpick comments (1)
template-compose/gradle/libs.versions.toml (1)

104-106: Keep Navigation 3 / Compose integrations out of the generic bundles.

Adding navigation3-* to bundles.androidx and hilt-lifecycle-viewmodel-compose to bundles.hilt makes those broad aliases navigation-aware. A dedicated navigation3 bundle, and optionally a hilt-compose bundle, will keep unrelated modules from picking these up accidentally.

Suggested catalog split
 androidx = [
     "androidx-core",
     "androidx-lifecycle-runtime",
     "androidx-lifecycle-compose",
-    "androidx-lifecycle-viewmodel-navigation3",
-    "androidx-navigation3-runtime",
-    "androidx-navigation3-ui",
 ]
+navigation3 = [
+    "androidx-lifecycle-viewmodel-navigation3",
+    "androidx-navigation3-runtime",
+    "androidx-navigation3-ui",
+]
 hilt = [
     "hilt-android",
-    "hilt-lifecycle-viewmodel-compose",
 ]
+hilt-compose = [
+    "hilt-lifecycle-viewmodel-compose",
+]

Also applies to: 115-115

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

In `@template-compose/gradle/libs.versions.toml` around lines 104 - 106, The
androidx navigation3 entries ("androidx-lifecycle-viewmodel-navigation3",
"androidx-navigation3-runtime", "androidx-navigation3-ui") and the hilt-compose
alias (hilt-lifecycle-viewmodel-compose) were added into broad bundles
(bundles.androidx and bundles.hilt); move these items out of those generic
bundles and instead create a dedicated navigation3 bundle (e.g.,
bundles.navigation3) that contains the three navigation3 artifacts, and
optionally a bundles.hilt-compose (or hilt-compose) bundle that contains
hilt-lifecycle-viewmodel-compose so that unrelated modules using
bundles.androidx or bundles.hilt do not become navigation- or compose-aware
inadvertently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt`:
- Around line 28-63: The match() function currently only checks path/query shape
and can succeed for URIs with the wrong host/scheme; add an early guard that
rejects mismatched scheme/authority before any path parsing. Specifically, in
match() compare the request URI's scheme and authority (or host+port) to the
deepLinkPattern's expected scheme/authority (derived from
deepLinkPattern.uriPattern or parsed Uri) and return null immediately if they
differ, then continue to existing path and matchQueryArgs() logic so only URIs
for the intended domain proceed to argument parsing and returning a
DeepLinkMatchResult.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt`:
- Around line 104-105: removeResult currently closes and removes the channel
(channelMap) which completes the active LaunchedEffect collector in ResultEffect
so it never re-subscribes when sendResult creates a new channel; change
removeResult (and its use of channelMap) to stop closing and removing the
existing channel—leave the channel instance intact so the existing
LaunchedEffect continues collecting; if you need explicit cleanup, add a
dedicated lifecycle cleanup invoked when the composable is disposed rather than
closing the channel inside removeResult.

---

Duplicate comments:
In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt`:
- Around line 68-71: DeepLinkPattern construction currently calls
serializer.descriptor.getElementIndex(argName) and immediately passes the result
into getElementDescriptor, which will crash if getElementIndex returns the
"unknown-name" sentinel; instead, after computing elementIndex (from
serializer.descriptor.getElementIndex(argName)), validate that it is not the
sentinel (e.g., negative/SerialDescriptor.UNKNOWN_NAME index) and if it is,
throw or return a clear configuration error mentioning the invalid placeholder
name and the DeepLinkPattern template; apply the same check for the other
occurrence around the code that handles query/path placeholders (the similar
block at lines 87-89) so typos in URI templates produce readable errors rather
than runtime crashes.

---

Nitpick comments:
In `@template-compose/gradle/libs.versions.toml`:
- Around line 104-106: The androidx navigation3 entries
("androidx-lifecycle-viewmodel-navigation3", "androidx-navigation3-runtime",
"androidx-navigation3-ui") and the hilt-compose alias
(hilt-lifecycle-viewmodel-compose) were added into broad bundles
(bundles.androidx and bundles.hilt); move these items out of those generic
bundles and instead create a dedicated navigation3 bundle (e.g.,
bundles.navigation3) that contains the three navigation3 artifacts, and
optionally a bundles.hilt-compose (or hilt-compose) bundle that contains
hilt-lifecycle-viewmodel-compose so that unrelated modules using
bundles.androidx or bundles.hilt do not become navigation- or compose-aware
inadvertently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d55a8592-bbe4-4ad1-8d0a-2fdc879c3dbd

📥 Commits

Reviewing files that changed from the base of the PR and between 46cd22d and 82ed328.

📒 Files selected for processing (11)
  • template-compose/app/build.gradle.kts
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt
  • template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt
  • template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt
  • template-compose/gradle/libs.versions.toml
🚧 Files skipped from review as they are similar to previous changes (2)
  • template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt
  • template-compose/app/build.gradle.kts

@RkNaing
Copy link
Copy Markdown

RkNaing commented Mar 11, 2026

Hi @eeeeaa 👋 In real projects, navigation logic can grow quickly. With Nav3's entry-based system, would it make sense to organize entries into a dedicated navigation.entries package — one file per screen? This structure should also scale well as the app grows and more screens are added.💭 cc: @hoangnguyen92dn

co.nimblehq.template.compose
└── navigation
    └── entries
        ├── AccountScreenNavEntry.kt
        ├── HomeScreenNavEntry.kt
        ├── LoginScreenNavEntry.kt
        └── CartScreenNavEntry.kt

For example, the HomeScreenNavEntry.kt.

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

data object Home : NavKey

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

Additionally, we can also have a dedicated AppNavigation.

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.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
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.navigation.entries.homeScreenEntry
import co.nimblehq.template.compose.util.LocalResultEventBus
import co.nimblehq.template.compose.util.ResultEventBus

private const val TWEEN_DURATION_IN_MILLIS = 500

@Composable
fun AppNavigation(
    navigator: Navigator,
    modifier: Modifier = Modifier,
) {
    val eventBus = remember { ResultEventBus() }
    CompositionLocalProvider(LocalResultEventBus.provides(eventBus)) {
        NavDisplay(
            backStack = navigator.backStack,
            onBack = { navigator.goBack() },
            entryDecorators = listOf(
                rememberSaveableStateHolderNavEntryDecorator(),
                rememberViewModelStoreNavEntryDecorator()
            ),
            transitionSpec = { horizontalSlideTransition(isPush = true) },
            popTransitionSpec = { horizontalSlideTransition(isPush = false) },
            predictivePopTransitionSpec = { horizontalSlideTransition(isPush = false) },
            modifier = modifier,
            entryProvider = entryProvider {
                homeScreenEntry(navigator)
                accountScreenEntry(navigator,profile)
                cartScreenEntry(navigator,cart)
            }
        )
    }
}

/**
 * Creates a horizontal slide transition. If [isPush] is `true`, slides from right to left (Forward).
 * Otherwise, slides from left to right (Backward/Pop).
 */
private fun horizontalSlideTransition(isPush: Boolean): ContentTransform {
    val direction = if (isPush) 1 else -1
    val spec = tween<IntOffset>(TWEEN_DURATION_IN_MILLIS)

    return slideInHorizontally(
        initialOffsetX = { it * direction },
        animationSpec = spec
    ) togetherWith slideOutHorizontally(
        targetOffsetX = { -it * direction },
        animationSpec = spec
    )
}

@eeeeaa eeeeaa force-pushed the chore/#617-migrate-to-navigation-3-template-compose branch from 82ed328 to 318069d Compare March 12, 2026 07:39
@eeeeaa eeeeaa force-pushed the chore/#617-migrate-to-navigation-3-template-compose branch from 318069d to 0e6b26d Compare March 12, 2026 07:47
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt (1)

64-64: Consider removing debug log in production code.

The Timber.d("Result : $uiModels") line appears to be debug logging. Consider removing it or guarding it for debug builds only to avoid unnecessary logging in release builds.

🤖 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/ui/screens/main/home/HomeScreen.kt`
at line 64, Remove the leftover debug log call Timber.d("Result : $uiModels")
from the HomeScreen composable (or guard it behind a debug-only check); either
delete the Timber.d invocation in HomeScreen.kt or wrap it with a
BuildConfig.DEBUG / debug-only guard so the message is not emitted in release
builds.
template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt (1)

11-11: Consider adding @Serializable annotation to Home for deep link support.

The Home NavKey should be annotated with @Serializable if it will be used with the deep link system. Looking at MainActivity.kt, the DeepLinkPattern example uses Home.serializer(), which requires the @Serializable annotation.

♻️ Proposed fix
+import kotlinx.serialization.Serializable
+
+@Serializable
 data object Home : NavKey
🤖 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/navigation/entries/HomeScreenNavEntry.kt`
at line 11, The Home NavKey data object lacks the `@Serializable` annotation
required by the deep link system; annotate the data object Home (data object
Home : NavKey) with `@Serializable` and add the necessary kotlinx.serialization
import so calls like Home.serializer() in MainActivity.kt / DeepLinkPattern
compile and work correctly.
template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt (1)

1-37: Consider moving FakeNavigator to mirror source directory structure.

As per coding guidelines, test files should mirror the source directory structure. Since Navigator is located at navigation/Navigator.kt, consider moving FakeNavigator to src/test/java/.../navigation/FakeNavigator.kt for consistency.

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

In
`@template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt`
around lines 1 - 37, The test helper FakeNavigator currently lives under
ui.screens but should mirror the production package for consistency; move the
FakeNavigator class so its package matches the Navigator source package (e.g.,
package co.nimblehq.template.compose.navigation) and place the file under the
test tree in the corresponding navigation directory
(src/test/java/.../navigation/FakeNavigator.kt), ensuring imports and references
to Navigator, Home, and KClass remain correct and tests compile.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt`:
- Around line 44-47: LaunchedEffect currently only keys on resultKey and
force-casts Flow<Any?> elements to T, which can throw ClassCastException and
fails to restart when the CompositionLocal-bound resultEventBus changes; update
the LaunchedEffect to key on both resultKey and resultEventBus (e.g.,
LaunchedEffect(resultKey, resultEventBus)) and guard the payload before casting
by using a safe cast (use result as? T) and only call onResult(result) when the
cast succeeds (otherwise ignore or log the unexpected type) — change references
around resultEventBus.ensureChannelAndGetFlow(...) and onResult to implement
these two fixes.
- Around line 40-45: The LaunchedEffect currently uses only resultKey as its key
so it won't restart when a new ResultEventBus instance is passed; update the
LaunchedEffect call in the function that takes resultEventBus (defaulting to
LocalResultEventBus.current) to include resultEventBus alongside resultKey as
keys (e.g., LaunchedEffect(resultKey, resultEventBus)) so the coroutine that
collects from resultEventBus is restarted when the bus instance changes; ensure
the rest of the block (including the `@Suppress`("UNCHECKED_CAST") and onResult
collection) remains unchanged.

---

Nitpick comments:
In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt`:
- Line 11: The Home NavKey data object lacks the `@Serializable` annotation
required by the deep link system; annotate the data object Home (data object
Home : NavKey) with `@Serializable` and add the necessary kotlinx.serialization
import so calls like Home.serializer() in MainActivity.kt / DeepLinkPattern
compile and work correctly.

In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt`:
- Line 64: Remove the leftover debug log call Timber.d("Result : $uiModels")
from the HomeScreen composable (or guard it behind a debug-only check); either
delete the Timber.d invocation in HomeScreen.kt or wrap it with a
BuildConfig.DEBUG / debug-only guard so the message is not emitted in release
builds.

In
`@template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt`:
- Around line 1-37: The test helper FakeNavigator currently lives under
ui.screens but should mirror the production package for consistency; move the
FakeNavigator class so its package matches the Navigator source package (e.g.,
package co.nimblehq.template.compose.navigation) and place the file under the
test tree in the corresponding navigation directory
(src/test/java/.../navigation/FakeNavigator.kt), ensuring imports and references
to Navigator, Home, and KClass remain correct and tests compile.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66223557-3b14-4109-b9be-dff645dbf508

📥 Commits

Reviewing files that changed from the base of the PR and between 82ed328 and 318069d.

📒 Files selected for processing (15)
  • template-compose/app/build.gradle.kts
  • template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/AppNavigation.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEffect.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt
  • template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt
  • template-compose/data/src/test/java/co/nimblehq/template/compose/data/extensions/ResponseMappingTest.kt
  • template-compose/gradle/libs.versions.toml
🚧 Files skipped from review as they are similar to previous changes (6)
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkRequest.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkMatcher.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/ResultEventBus.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/DeepLinkPattern.kt
  • template-compose/app/build.gradle.kts
  • template-compose/app/src/main/java/co/nimblehq/template/compose/util/KeyDecoder.kt

@coderabbitai coderabbitai bot temporarily deployed to template-compose March 12, 2026 07:50 Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt`:
- Around line 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.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 61334500-7ce5-4a6d-83f7-d1cabd182e94

📥 Commits

Reviewing files that changed from the base of the PR and between 318069d and 0e6b26d.

📒 Files selected for processing (6)
  • template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/main/MainActivityModule.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/AppNavigation.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/MainActivity.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt
  • template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt
🚧 Files skipped from review as they are similar to previous changes (3)
  • template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/FakeNavigator.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeScreen.kt
  • template-compose/app/src/main/java/co/nimblehq/template/compose/navigation/entries/HomeScreenNavEntry.kt

Comment on lines +16 to +21
@InstallIn(ActivityRetainedComponent::class)
object MainActivityModule {

@Provides
@ActivityRetainedScoped
fun provideNavigator(): Navigator = NavigatorImpl(startDestination = Home)
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.

@eeeeaa eeeeaa deployed to template-compose March 13, 2026 03:01 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants