Skip to content

Migrate from Navigation 2 to Navigation 3#1315

Merged
Elforama merged 6 commits into
masterfrom
nav3-migration
Jul 16, 2026
Merged

Migrate from Navigation 2 to Navigation 3#1315
Elforama merged 6 commits into
masterfrom
nav3-migration

Conversation

@Elforama

@Elforama Elforama commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What & why

Migrates the app from Jetpack Navigation 2 (androidx.navigation:navigation-compose 2.8.5) to Navigation 3 (androidx.navigation3 1.1.1). Navigation 3 is Google's Compose-native successor: the app owns the back stack (NavBackStack) instead of a NavController, destinations are NavKeys rendered by NavDisplay, and ViewModel/saved-state scoping is done with entry decorators.

Every existing navigation use case is reimplemented — nothing is dropped. navigation-compose is fully removed (zero androidx.navigation. imports remain).

How it's structured (4 commits)

  1. Deps bump (isolated first): Compose 1.7.5 → 1.10.5 and compileSdk 35 → 36 (both required by navigation3-ui 1.1.1); material-icons-extended pinned to 1.7.8 (its last release); androidx.activity → 1.12.0. Green build on Nav2 before any nav-code change.
  2. The migration: all 29 routes implement NavKey; new Navigator + NavOptions DSL (popUpTo<T>, popUpToRoot() replacing popUpTo(graph.id), launchSingleTop, popBackStackTo<T>) replacing NavHostController; NavHostNavDisplay in both Host.kt and ImageCaptureActivity; trackedComposable → a custom screen-tracking NavEntryDecorator (keeps Crashlytics breadcrumbs); deep link parsed manually from the launch intent (Nav3 has no deep-link support — cold-start only, matching prior behavior).
  3. Runtime fix: NavDisplay's back handling needs a NavigationEventDispatcherOwner, which AppCompatActivity.setContentView (appcompat 1.7.x) doesn't plant — caught on the emulator, fixed by planting the view-tree owner in TreeTrackerActivity.
  4. Tests: NavigatorTest (16 cases), OrgDeepLinkTest (10), RouteRegistryTest (8), and two SettingsViewModelTest cases that execute the NavigationEvent lambda against a FakeNavigator to cover the event → Navigator wiring and the new popUpToRoot() logout path.

Behavior notes for reviewers

  • launchSingleTop is implemented as replace-top (recreates the entry if args differ) — unobservable for all current call sites, but documented in Navigator.
  • The old "only navigate while RESUMED" throttle guard is replaced by a 300 ms debounce plus an origin-is-top check in HandleUIEvents (a nav event only fires when its hosting entry is the top of the stack). Net effect on double-taps is equal or stronger; a nav issued 300–500 ms after another (mid-fade) now succeeds where the RESUMED check dropped it.
  • Predictive-back gesture now animates by default (13+) with the same 500 ms fade as the other transitions.
  • Deep-link parity: cold-start only. A link arriving while the app is running is still ignored (singleTask, no onNewIntent), same as before.

Task write-up: docs/tasks/task_24/.

Verification

./gradlew :app:assembleDebug :app:testDebugUnitTest :app:verifyRoborazziDebug :app:ktlintCheck :app:detekt :app:lintDebug all pass.

Manually driven end-to-end on a Pixel 7 API 35 emulator (no crashes throughout):

  • Fresh install → language select → privacy policy → phone signup → selfie capture via ImageCaptureActivity (the second, standalone NavDisplay; retake + approve) → dashboard.
  • Tree capture flow: dashboard → TRACK → user select → capture setup → TreeCapture → snap → save → loops back to TreeCapture with no stack growth across 3 trees → system back → dashboard shows count 3, session ended.
  • Signup-flow back handling; org deep-link cold start (adb ... -d "app://mobile.treetracker.org/org?id=...&name=..." → logcat OrgLink: Deeplink received).

📸 Proof of change (REQUIRED)

🎥 Screen recording / video (always required)

⚠️ Author action needed: a screen recording still needs to be attached here before merge — I verified the flows live on the emulator (screenshots below) but a video file must be drag-dropped in by a human.

🖼️ Screenshots (UI change — transitions/back behavior)

This is primarily a refactor: screens render identically and the 500 ms fade transitions are preserved. The visible surface exercised during verification (dashboard after capturing 3 trees, capture screen, selfie flow) is unchanged from master. Before/after screenshots to be attached alongside the recording.

  • Behavior is a navigation-internals refactor; screens are visually unchanged. A video is still required and will be added by the author.

Fixes #<issue_number_goes_here> 🦕

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

👋 Hi @Elforama, thanks for the contribution!

Before this PR can be reviewed and merged, please add:

  • a 🎥 screen recording / video demonstrating the change — a video is required even when there are no UI changes, to show that the parts of the app affected by this change still work
  • a 🖼️ screenshot of the UI change (or mark the PR as non-ui if there is no UI change)

Just drag-and-drop the file(s) into the PR description and GitHub will upload them. This check re-runs automatically when you edit the description.

If this change has no user-visible effect, tick the "no user-visible / UI effect" box in the description (or ask a maintainer to add the non-ui label) to skip the screenshot requirement. A video is still required even with no UI changes, to show that the affected parts of the app still work.

Elforama and others added 5 commits July 16, 2026 13:44
… 3 deps

Prerequisite for the Navigation 2 -> Navigation 3 migration:
navigation3-ui 1.1.1 requires Compose >= 1.10.0 and compileSdk 36.
material-icons-extended stays at 1.7.8 (last published version).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace androidx.navigation:navigation-compose with androidx.navigation3:

- Routes implement NavKey; back stack owned by the app via rememberNavBackStack
- New Navigator + NavOptions DSL (popUpTo<T>, popUpToRoot, launchSingleTop)
  replacing NavHostController and NavigationUtils' throttled helpers
- NavHost -> NavDisplay in Host.kt and ImageCaptureActivity, with saveable-state
  and ViewModel-store entry decorators and the same 500ms fade transitions
- trackedComposable -> custom screen-tracking NavEntryDecorator (Crashlytics
  breadcrumbs); HandleUIEvents gains an origin-is-top guard replacing the
  Nav2 RESUMED check
- Org deep link parsed manually from the launch intent (cold start only,
  matching previous behavior)
- NavigatorTest + FakeNavigator; previews/screenshot harness on LocalNavigator

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
NavDisplay's back handling crashed on launch: AppCompatActivity's
setContentView (appcompat 1.7.x) plants only the pre-navigationevent
view-tree owners, so nav3 could not find a NavigationEventDispatcher.

- Bump androidx.activity to 1.12.0, where ComponentActivity implements
  NavigationEventDispatcherOwner
- Plant the view-tree owner on the decor view in TreeTrackerActivity

Verified on a Pixel 7 API 35 emulator: cold start, splash auto-nav,
signup back handling, and org deep link all work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ution

- OrgDeepLinkTest (10 cases): parseStartRoute over valid/invalid scheme,
  host, path, query params, and URL-encoding (Robolectric for android.net.Uri)
- RouteRegistryTest (8 cases): no-arg resolution to NavKey, arg-route null,
  alias normalization, isValidRoute
- SettingsViewModelTest: two cases now execute the NavigationEvent lambda
  against a FakeNavigator and assert the resulting back stack, covering the
  event -> Navigator wiring and the new popUpToRoot() logout path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 300 ms navigation throttle lived on a single lastMutationTime field on
the one Navigator per NavDisplay, making it a global lock across every screen
and button: navigating on one screen dropped any navigation from another for
300 ms, so a user could not tap "next" through a flow quickly.

Bind each screen its own origin-scoped Navigator (via the per-entry decorator)
and gate throttled calls on "is my screen still the top of the stack?" rather
than a wall-clock timer:

- Navigator gains an `origin` binding and `scopedTo(originContentKey)`;
  isThrottled() drops a throttled call only when the caller's origin is no
  longer on top. Unscoped navigators keep the time-based debounce as a fallback
  for tests and non-entry hosts.
- rememberScreenTrackingNavEntryDecorator re-provides LocalNavigator as a view
  scoped to the entry's contentKey, so every screen gets per-screen double-tap
  protection with no call-site changes.

Result: rapid "next" across different screens all succeed (no time gap), while
a double-tap on one screen drops the second navigation (origin no longer top).
Also removes the side effect where a non-throttled navigate armed the window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reduce the NavDisplay transition duration from 500ms to 300ms. On a pop,
NavDisplay deliberately keeps the outgoing screen on top for the full fade
(targetZIndex = initialZIndex - 1f), so during that window it intercepts taps
meant for the revealed screen behind it. Halving the window makes tapping the
revealed screen right after Back land reliably, and makes transitions snappier
overall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Elforama
Elforama merged commit a26febd into master Jul 16, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant