Replace MainActivity's three independent booleans (browsingCode/ browsingHistory/browsingWorkItems) with one typed List<Route> back stack, restorable through configuration change and process death. Route holds only stable primitives (owner/name/ref/ref-kind), never tokens or content. A genuine LIFO stack, not just a refactor: back now correctly unwinds through arbitrarily nested screens instead of relying on which boolean happened to still be true underneath another. No task file existed for this task beforehand (only proposed in an earlier handoff document); created it first per AGENTS.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7.3 KiB
TASK-013: Typed application navigation
Status: implemented; all required checks pass; pending Codex review Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26) Reviewer: Codex
Outcome
Replace MainActivity's ad-hoc browsingCode/browsingHistory/browsingWorkItems booleans with a typed route stack that survives configuration change and process recreation, with well-defined back behavior between the repository list, code browser, history, and work-items screens.
Scope
- Included:
- A
Routesealed type inui/navigation/holding only stable primitives (owner, repo name, ref kind/value, path, item kind/number) — never tokens, authorization codes, or fetched content. - A back stack (
List<Route>) owned byMainActivity, restorable through configuration change and process death via arememberSaveablecustom Saver. - On any route becoming current (fresh navigation or restoration), the owning feature ViewModel is told to (re)load that route's target — this re-fetches content rather than persisting fetched data itself, consistent with the rest of the app's no-offline-cache posture.
- Back navigation pops the route stack; a feature's own internal back behavior (e.g. code browser directory-up, history detail-to-list) is unchanged and still owned by that feature's ViewModel.
- Authentication expiry clears the route stack back to
Repositoriesin addition to the existing per-featureclose()+ logout behavior.
- A
- Excluded:
- Restoring deep in-feature state (which file is open mid-scroll, which page of a paginated list, which review is expanded) — only the route's identifying target is restored; the feature reloads fresh from there. This matches the explicit scope note in the prior serial-handoff roadmap ("Current screen restoration is not a full typed back stack; TASK-013 owns it" referred to route-level restoration, not full UI-state persistence).
- Migrating to Navigation Compose or any other new dependency — the app's screen graph is four shallow, mostly-linear screens; a hand-rolled sealed
Route+List<Route>stack is sufficient and avoids a dependencyAGENTS.mdwould otherwise require justifying.
Writable paths
app/src/main/java/com/ubisam/ubgitea/ui/navigation/app/src/test/java/com/ubisam/ubgitea/ui/navigation/app/src/main/java/com/ubisam/ubgitea/MainActivity.kttasks/013-typed-navigation.md
Acceptance criteria
Routestores only stable primitives; no token, authorization code, or fetched-content field exists on any route —RouteisRepositories | CodeBrowser(owner,name,refKind,ref) | History(owner,name,ref) | WorkItems(owner,name), allStringfields.- The route stack is restorable through configuration change and process recreation (custom
Saver) —RouteStackSaverencodes each route as a delimited string (List<String>is directly Bundle-safe), using U+001F as the field separator, whichRepositoryCoordinates/RepositoryRefalready guarantee cannot appear in real field values (both reject ISO control characters). - Back pops exactly one route; popping to a
CodeBrowser/History/WorkItemsroute re-triggers that feature's load for the restored target instead of showing stale/empty state — a singleLaunchedEffect(currentRoute)inMainActivitycalls the owning ViewModel'sopen(...)whenever the top-of-stack route changes, for both fresh navigation and restoration (they're indistinguishable to this effect, which is the point). - Authentication expiry clears the route stack back to
Repositories— one sharedauthenticationExpired()closes every feature ViewModel, resets the stack to[Repositories], and logs out; wired identically from all three feature screens'onAuthenticationExpired. - Tests cover route encoding/decoding round-trip (the
Saver) and back-stack push/pop behavior — 6 tests including a ref containing/, an unrecognized-encoding fallback, and a full multi-route stack round-tripped through the actualSaver.save/restorefunctions (not just the internal encode/decode helpers).
Required checks
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
Evidence
Implemented on branch claude/task-013-navigation from commit c397fbe (includes TASK-007/008/009/010, none yet Codex-reviewed).
- New:
ui/navigation/Route.kt,ui/navigation/RouteStackSaver.kt,RouteEncodingTest.kt. Rewrote:MainActivity.kt— replaced the three independentrememberSaveablebooleans (browsingCode/browsingHistory/browsingWorkItems) with oneList<Route>back stack. - Genuine behavior improvement, not just a refactor: the old booleans could only model two fixed nesting paths (code-browser→history was correct only because
browsingCodehappened to staytrueunderneathbrowsingHistory; work-items had no nesting). The new stack is a real LIFO — pushingHistoryon top ofCodeBrowseron top ofRepositoriesmeans back from history lands on code browser, back from code browser lands on repositories, and any future screen added to the stack gets correct back behavior for free instead of needing another hand-wired boolean and another special case in the nestedif/else. listSaver's actual Compose signature returnsSaver<List<T>, Any>(notSaver<List<T>, List<Saveable>>as its type parameters might suggest) — this was discovered by letting the compiler's type-mismatch error report the real inferred type rather than guessing, andRouteStackSaver's declared type was corrected to match.- Feature-internal back behavior (code browser directory-up, history detail-to-list, work-items detail-to-list) is unchanged — it is still owned by each feature's own ViewModel and is not part of the route stack, consistent with this task's explicitly bounded scope.
Test coverage (6 cases in RouteEncodingTest): Repositories round-trip (1), CodeBrowser round-trip including a ref containing / (1), History round-trip (1), WorkItems round-trip (1), an unrecognized encoding falls back to Repositories instead of throwing (1), a full three-route stack round-tripped through the real Saver.save/restore functions in order (1).
Checks run from the new worktree (local.properties created locally, not committed):
./gradlew testDebugUnitTest --tests "com.ubisam.ubgitea.ui.navigation.RouteEncodingTest" → BUILD SUCCESSFUL, 6/6 passed
./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL
- 164 unit tests across 28 suites, 0 failures/errors
- 0 lint errors
git diff --check → clean (CRLF notice only)
sensitive-pattern scan of changed files → no matches
Known limitations:
- Only the route (which screen + its identifying target) survives process death, not deeper in-feature state (open commit, scroll position, which page) — explicitly excluded in Scope above.
- No physical device was available to verify process-death restoration interactively (this can only be fully confirmed with "don't keep activities" + an actual kill, which requires a device); the
Saverround-trip itself is unit-tested directly instead.
No open P0-P3 findings from self-review. Requesting Codex read-only review.