Skip to content

fix(settings): retain config session across navigation - #6449

Merged
jamesarich merged 4 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/settings-navigation-session
Jul 27, 2026
Merged

fix(settings): retain config session across navigation#6449
jamesarich merged 4 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/settings-navigation-session

Conversation

@jeremiah-k

@jeremiah-k jeremiah-k commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Overview

Navigation 3 gives each navigation entry its own ViewModelStoreOwner. Resolving RadioConfigViewModel independently from each entry therefore recreated the radio-configuration session while moving between configuration pages, restarting its collectors and losing transient state.

This change introduces retained local and per-node remote configuration sessions shared across their navigation entries on Android and Desktop. Session stores survive Android configuration changes, remain available while outgoing transition entries are still composed, and are cleared once the corresponding configuration flow is no longer active and its final entry lease has been released.

Direct local configuration routes, including Connections → LoRa, participate in the same retained-session lifecycle even when the back stack does not contain a Settings root.

Session lifecycle

  • Model inactive, local, and per-node remote configuration sessions explicitly.
  • Keep child ViewModelStores in a shell-scoped holder ViewModel so the active session survives Android configuration recreation.
  • Reuse one RadioConfigViewModel while navigating within the same local or remote configuration session.
  • Preserve each Settings root’s exact destination while outgoing and incoming entries overlap.
  • Lease a session store for each composed navigation entry, delaying eviction until its exit transition is complete.
  • Clear abandoned local or remote stores and cancel their collectors after their final lease is released.
  • End the session when the user leaves the active radio-configuration flow.
  • Start the appropriate new local or remote session when the user later returns.

Loading behavior

  • Continue requesting route-specific configuration when a configuration page opens.
  • Keep the existing connect-time configuration visible while local refreshes complete.
  • Avoid briefly replacing local content with a full-screen 0% progress overlay.
  • Continue showing blocking progress for genuinely remote configuration reads.
  • Continue showing progress for saves and other explicit configuration-changing operations.
  • Treat an explicitly addressed local node as local rather than inferring that every non-null destination is remote.

Navigation integration

  • Pass the retained provider through the Android and Desktop navigation hosts.
  • Observe back-stack changes with snapshotFlow and emit only logical session changes.
  • Support direct radio-configuration routes opened from outside the Settings root.
  • Remove the unused entry-scoped production fallback.
  • Prevent duplicate pushes of the current Settings route.
  • Pass the exact Settings-root arguments to the provider during overlapping transitions.
  • Add and apply a real Modifier parameter to DesktopMainScreen.
  • Keep the Desktop suppression limited to ViewModelForwarding.

Testing

Added focused coverage for:

  • inactive, local, and per-node remote session classification;
  • direct local configuration routes outside the Settings tab;
  • unrelated settings routes remaining inactive;
  • reuse of the same store within one session;
  • configuration recreation retaining the active store;
  • direct-route handoff without a zero-lease store clear;
  • destination changes and configuration-flow exits;
  • delayed clearing while outgoing entries remain composed;
  • explicit Settings-root destination precedence;
  • safe fallback during inactive exit transitions;
  • duplicate-route suppression;
  • non-blocking local route refreshes;
  • blocking remote route refreshes;
  • explicitly addressed local-node refreshes;
  • progress behavior for direct configuration operations.

Scope

  • Radio configuration payloads and firmware behavior are unchanged.
  • Local and remote refresh requests are still performed.
  • Session retention is limited to the active radio-configuration flow; collectors are not retained throughout unrelated app navigation.
  • This removes redundant ViewModel creation and transient local loading overlays but does not claim a measured performance improvement.
  • No user data migration is required.

Summary by CodeRabbit

  • Improvements

    • Settings navigation now preserves the correct configuration state when switching between local and remote devices.
    • Configuration screens provide clearer loading behavior, including progress overlays only when appropriate.
    • Navigation returns to the appropriate starting screen based on whether a device is connected.
  • Bug Fixes

    • Improved cleanup and retention of settings state when changing destinations, leaving settings, or recreating the app.
    • Prevented duplicate settings routes from being added.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Settings navigation now resolves RadioConfigViewModel instances through retained local and remote sessions. Android and desktop entry points provide the session-aware provider, while route loading exposes overlay state and tests cover navigation, lifecycle, and loading behavior.

Changes

Settings radio configuration

Layer / File(s) Summary
Session-scoped settings navigation
feature/settings/src/commonMain/..., feature/settings/src/commonTest/...
Settings routes use retained local/remote session stores to resolve radio configuration view models, manage lifecycle leases, and prevent duplicate route insertion. Navigation tests cover session resolution and store cleanup.
Radio configuration loading states
feature/settings/src/commonMain/.../radio/*, feature/settings/src/commonTest/.../radio/*
Config and module route loading is dispatched through dedicated helpers, ResponseState.Loading carries overlay visibility, and loading UI plus tests reflect local and remote behavior.
Android and desktop provider wiring
androidApp/src/main/.../Main.kt, desktopApp/src/main/.../DesktopNavigation.kt, desktopApp/src/main/.../DesktopMainScreen.kt, androidApp/src/test/.../NavigationAssemblyTest.kt
Android and desktop navigation pass settings view-model providers into settingsGraph; Android centralizes initial route selection and assembly tests supply the new provider argument.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MainScreen
  participant DesktopMainScreen
  participant settingsGraph
  participant settingsRadioConfigViewModelProvider
  participant RadioConfigViewModel
  MainScreen->>settingsGraph: pass session-aware provider
  DesktopMainScreen->>settingsGraph: pass session-aware provider
  settingsGraph->>settingsRadioConfigViewModelProvider: resolve entry session
  settingsRadioConfigViewModelProvider->>RadioConfigViewModel: create or retrieve scoped instance
  RadioConfigViewModel-->>settingsGraph: provide route configuration state
Loading

Possibly related PRs

Suggested labels: ui

Suggested reviewers: jamesarich

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: retaining settings config sessions across navigation.

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.

@github-actions github-actions Bot added bugfix PR tag desktop Desktop target labels Jul 26, 2026
@jeremiah-k
jeremiah-k marked this pull request as ready for review July 26, 2026 18:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`:
- Around line 90-107: The settingsRadioConfigViewModel flow must stop retaining
abandoned remote RadioConfigViewModel instances in the app-shell ViewModelStore.
Tie each session to the relevant backstack-entry store/lifetime, or explicitly
remove/reset the previous settings-remote-* entry when session.destination
changes, ensuring collectors from the old destination are cancelled while
preserving the active local or remote session.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 423fea01-d290-4997-8d26-5bd182e4cf35

📥 Commits

Reviewing files that changed from the base of the PR and between 0949753 and 38d29eb.

📒 Files selected for processing (5)
  • androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
  • desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
  • desktopApp/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
  • feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigationTest.kt

@jeremiah-k
jeremiah-k marked this pull request as draft July 26, 2026 18:19
@jeremiah-k
jeremiah-k force-pushed the bugfix/settings-navigation-session branch from 38d29eb to c19fc6c Compare July 26, 2026 18:30
@jeremiah-k
jeremiah-k marked this pull request as ready for review July 26, 2026 18:41

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The diagnosis here is right and worth fixing: Navigation 3 gives every entry its own ViewModelStoreOwner, so getRadioConfigViewModel was minting a separate RadioConfigViewModel per settings entry — each one re-running eight launchIn(viewModelScope) collectors and its own remote fetch, with the "session" living in whichever instance the visible screen happened to resolve. Hoisting to one store keyed by destination is the right shape, and :feature:settings:allTests is green locally.

Two things I'd want addressed before merge, both downstream of holding the store in a plain remember.


1. Rotation now destroys the config session

SettingsNavigation.kt:107-111 keeps the owner in a plain remember and clears it from a DisposableEffect. MainActivity declares no configChanges (there is no configChanges/screenOrientation attribute anywhere in androidApp/src/main/AndroidManifest.xml), so rotation recreates the activity, disposes the composition, fires onDispose { owner.clear() }, and kills the RadioConfigViewModel mid-session — losing _radioConfigState, any in-flight responseState/pending save, and forcing ensureLoadingForRemote to refetch.

The entry-scoped path this replaces did not have that problem. MeshtasticNavDisplay.kt:132 installs rememberViewModelStoreNavEntryDecorator, whose stores are held by a ViewModelStoreProvider parented to the activity's store; upstream is explicit about it:

removeViewModelStoreOnPop: This parameter was a workaround for detecting configuration changes … Configuration changes are now handled internally.

Entry stores are cleared on onPop, not on composition disposal, so a config session survived rotation before this change and won't after. That's the same class of bug the PR is fixing, just triggered by a different gesture — and rotating mid-config is a pretty ordinary thing to do.

Suggested fix: park the per-session ViewModelStores in a retained holder ViewModel obtained at the shell scope (koinViewModel() in MainScreen/DesktopMainScreen), keyed by session.viewModelKey, clearing stores for keys that are no longer the active session. That keeps the cross-entry sharing this PR wants and the config-change retention nav3 was already giving you.

2. The local session is retained for the whole app-shell lifetime

settingsRadioConfigSession (SettingsNavigation.kt:137) returns destination = null whenever the active backstack contains no SettingsRoute.Settings — which is true on every non-settings tab, not just in local settings. SettingsRadioConfigSession is a data class over a single nullable Int, so remember(session) at :108 sees the same value on the Nodes tab, the Map tab, everywhere, and hands back the same owner. Net effect: once a user opens local settings, that RadioConfigViewModel is never cleared for the life of the composition, and its collectors keep running everywhere in the app — including serviceRepository.meshPacketFlow.onEach(::processPacketResponse) (RadioConfigViewModel.kt:344), which processes every mesh packet.

That's precisely the invariant the new KDoc claims to establish:

…and clears it when the destination changes, so an abandoned remote session cannot retain collectors for the rest of the activity or window lifetime.

True for remote sessions, inverted for the local one. SettingsRadioConfigSession needs to distinguish "no settings destination in the stack" (→ drop the session) from "local settings destination" (→ keep it), rather than collapsing both to null.


Non-blocking

  • Tab switching still tears the session down. activeBackStack swaps on tab change (MultiBackstack.kt:39-40), so leaving the Settings tab mid-remote-session flips the session to local, clears the remote owner, and returning rebuilds the ViewModel from scratch. Fine as a design choice, but the description's "retain the same RadioConfigViewModel while navigating within one local or remote settings session" reads stronger than what ships — worth stating the boundary.

  • The shell now recomposes on every navigation. rememberSettingsRadioConfigSession calls backStack.toList() outside remember (:134), so MainScreen/DesktopMainScreen subscribe to backstack mutations; previously the shell only read currentTabRoute. Every push/pop now invalidates the shell and rebuilds the entryProvider. Probably minor next to the ViewModel churn you're removing, but it does cut against the "improves perceived UI responsiveness" claim, so I'd either measure it or confine the read below the shell.

  • entryScopedRadioConfigViewModel is dead in production. Both Android (Main.kt:113) and Desktop pass a provider; the only caller of the default is androidApp/src/test/.../NavigationAssemblyTest.kt:57. So the default keeps a second code path compiling that nothing ships, and lets the assembly test assert a path no user hits. Either drop the default and update the test, or add a comment on why it stays.

  • @Suppress("ViewModelForwarding", "ModifierMissing") on DesktopMainScreenModifierMissing is unrelated to this change and the suppression is function-wide. Worth narrowing to just what the new lambda triggers.

  • Nit: entry<SettingsRoute.Settings> (:159) no longer honors args.destNum, deriving destNum from the last Settings key instead. Settings is single-pane so these agree at rest; while pushing Settings(1234) onto Settings() both entries are briefly composed and the outgoing local screen resolves the remote ViewModel for the crossfade. Cosmetic, and awkward to avoid given the shared owner — flagging only because the override was deliberate.

  • Tests. The new coverage is over the pure key-derivation helpers, plus one case (clearing a settings session store clears its view models) that mostly asserts ViewModelStore.clear() calls onCleared() — androidx's behavior, not this PR's. Neither of the two claims that actually matter is covered: that local → DeviceConfigurationLoRa yields the same ViewModel instance, and that changing destination clears the previous store. A Compose test over settingsRadioConfigViewModel with a fake owner would pin both, and would have caught (2).

@jeremiah-k
jeremiah-k marked this pull request as draft July 26, 2026 19:48
@jeremiah-k
jeremiah-k force-pushed the bugfix/settings-navigation-session branch from c19fc6c to 83c7091 Compare July 26, 2026 22:02
Resolve RadioConfigViewModel lazily from the app-shell ViewModelStoreOwner instead of each Navigation 3 entry so moving between Settings submenus no longer destroys and recreates the same radio-configuration session.

Use deterministic local and destination-specific keys to preserve remote-administration isolation while retaining a session for the activity or desktop-window lifetime. Keep the entry-scoped provider as the default for standalone graph consumers and align Android and Desktop hosts.

Derive the active destination from one immutable back-stack snapshot and reject only exact duplicate top-route pushes, preventing rapid double taps from creating redundant Settings entries without changing ordinary back navigation.

Keep host-level ViewModel forwarding explicit, remove obsolete navigation parameters, and isolate initial-route selection so the Android and Desktop assembly points remain within static-analysis limits. Cover destination inheritance, return to a newer local Settings root, and duplicate-route admission.
Derive the selected destination and both app-shell and entry-scoped ViewModel
keys from one SettingsRadioConfigSession so Android and Desktop navigation
cannot diverge between ownership paths.

Retain the active RadioConfigViewModel across that destination's Settings
submenus in a dedicated ViewModelStore. Replace and clear the store when the
local or remote destination changes, cancelling collectors owned by an
abandoned remote session instead of retaining every administered node for the
activity or window lifetime.

Cover local and remote key contracts and verify store disposal clears its
ViewModels.
Keep child ViewModelStores in a shell-scoped holder ViewModel so an active
local or remote configuration session survives Android configuration changes.
Model inactive, local, and per-node remote states separately, and observe only
logical session changes instead of invalidating the shell for every submenu
mutation.

Activate sessions after successful composition and lease each store for the
lifetime of its navigation entry. Destination changes and Settings-tab exits
therefore clear abandoned collectors only after the outgoing crossfade entry
is disposed. Capture each Settings root's exact destination so overlapping
local and remote entries cannot resolve one another's RadioConfigViewModel.

Remove the unused entry-scoped production fallback and narrow the Desktop
suppression to ViewModel forwarding. Cover store reuse across submenus and
configuration recreation, destination and tab eviction, transition fallback,
root-destination precedence, and duplicate-route admission.
@jeremiah-k
jeremiah-k force-pushed the bugfix/settings-navigation-session branch from 83c7091 to 82c533a Compare July 27, 2026 00:55
@jeremiah-k

Copy link
Copy Markdown
Contributor Author

@jamesarich Thanks for the detailed review. Both lifecycle blockers and the related implementation notes have now been addressed.

The session stores are owned by a shell-scoped retained ViewModel, so Android configuration recreation no longer clears an active RadioConfigViewModel. The session model explicitly distinguishes inactive, local, and per-node remote states, preventing the local session and its collectors from remaining active throughout unrelated app navigation.

Each composed navigation entry leases its captured session store. Destination changes and configuration-flow exits therefore retain the outgoing ViewModel only through its transition, then clear the abandoned store after the final lease is released. Settings roots pass their exact args, so overlapping local and remote entries cannot resolve one another’s ViewModel.

Back-stack observation now uses snapshotFlow with logical-session deduplication, the unused entry-scoped production fallback was removed, and DesktopMainScreen exposes and applies a real Modifier with only the ViewModel-forwarding suppression retained.

I also covered direct configuration routes such as Connections → LoRa, which do not have a Settings root but still require a retained local session. Local route refreshes now keep the existing connect-time configuration visible instead of briefly showing a 0% overlay; genuinely remote reads and explicit configuration operations continue to show progress.

The expanded tests cover same-session reuse, configuration recreation, direct-route handoff, destination changes, configuration-flow exit, transition-safe eviction, exact-root precedence, inactive fallback, duplicate-route suppression, and local-versus-remote loading behavior.

@jeremiah-k
jeremiah-k marked this pull request as ready for review July 27, 2026 01:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt (1)

454-459: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Key the pre-composition loading hook to the ViewModel.

remember { viewModel.ensureLoadingForRemote().let { true } } is unkeyed, so if the provider resolves a different RadioConfigViewModel for this entry (session switch), the blocking overlay is never primed for the new instance. The .let { true } wrapper is also obscure.

♻️ Suggested change
-        remember { viewModel.ensureLoadingForRemote().let { true } }
+        remember(viewModel) {
+            // Must run before the first collectAsStateWithLifecycle read so the overlay shows on frame one.
+            viewModel.ensureLoadingForRemote()
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
around lines 454 - 459, Update the pre-composition loading hook in the
addEntryProvider block to key remember by the resolved viewModel, ensuring
ensureLoadingForRemote() runs for each new RadioConfigViewModel instance.
Replace the obscure .let { true } wrapper with a direct remembered invocation
while preserving the blocking overlay behavior for remote settings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`:
- Around line 155-168: Update the store creation and retention flow around
storeFor/ownerFor so a newly created SessionStore for the current navigation
entry is protected from clearUnusedStores() before DisposableEffect retains it.
Seed one lease atomically when creating the current store, then release that
lease during disposal, while preserving existing lease-based cleanup for other
stores.

---

Nitpick comments:
In
`@feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`:
- Around line 454-459: Update the pre-composition loading hook in the
addEntryProvider block to key remember by the resolved viewModel, ensuring
ensureLoadingForRemote() runs for each new RadioConfigViewModel instance.
Replace the obscure .let { true } wrapper with a direct remembered invocation
while preserving the blocking overlay behavior for remote settings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 833157c0-8e1c-410f-ab4f-d68c35ed05c1

📥 Commits

Reviewing files that changed from the base of the PR and between c19fc6c and 82c533a.

📒 Files selected for processing (10)
  • androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt
  • androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
  • desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
  • desktopApp/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ResponseState.kt
  • feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt
  • feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigationTest.kt
  • feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt

Comment on lines +155 to +168
private fun storeFor(session: SettingsRadioConfigSession.Active): SessionStore =
stores.getOrPut(session, ::SessionStore)

private fun clearUnusedStores() {
val current = activeSession as? SettingsRadioConfigSession.Active
val iterator = stores.iterator()
while (iterator.hasNext()) {
val (session, store) = iterator.next()
if (session != current && store.leases == 0) {
store.owner.clear()
iterator.remove()
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for any test covering a cross-session transition where the outgoing entry releases
# after the incoming entry's store is created.
rg -n -C5 'release\(|retain\(|ownerFor\(' feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigationTest.kt

Repository: meshtastic/Meshtastic-Android

Length of output: 4507


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt --view expanded || true

echo "== relevant settings navigation source =="
wc -l feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
sed -n '80,185p' feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt | cat -n

echo "== resolveEntrySession and usages/Navigation call sites =="
rg -n -C4 'resolveEntrySession|SettingsRadioConfigSessionHolder|SettingsRadioConfigViewModel|settingsRadioConfigSession|ownerFor|activate\(|retain\(|release\(' feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt

Repository: meshtastic/Meshtastic-Android

Length of output: 12931


Protect zero-lease stores created for the current navigation entry.

In a cross-session transition, the incoming entry can create a SessionStore during composition while a different outgoing entry disposes in the same apply pass; because the incoming store has not yet been retained by DisposableEffect, clearUnusedStores() clears it and removes it from the map. Make creation and retention atomic, for example by seeding the lease when calling ownerFor and releasing it on dispose.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
around lines 155 - 168, Update the store creation and retention flow around
storeFor/ownerFor so a newly created SessionStore for the current navigation
entry is protected from clearUnusedStores() before DisposableEffect retains it.
Seed one lease atomically when creating the current store, then release that
lease during disposal, while preserving existing lease-based cleanup for other
stores.

@jeremiah-k
jeremiah-k marked this pull request as draft July 27, 2026 01:32

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed at 82c533a. Both blockers are properly addressed, and the fixes go further than what I asked for — thanks for that.

Rotation (was blocking). SettingsRadioConfigSessionHolder is a retained @KoinViewModel resolved from the shell's LocalViewModelStoreOwner (the activity), so the per-session stores now outlive activity recreation. The lease design is the part I'd have gotten wrong: activate() marking a session current, plus refcounted retain/release from each entry's DisposableEffect, means the store survives the window where the old composition has released but the new one hasn't acquired. I traced both interleavings — release-then-activate and activate-then-release — and they converge on the same eviction either way, which is what configuration recreation keeps the active settings store and direct local route transition does not clear its active store between entries pin down. onCleared sweeping the map closes the last exit.

Local session retention (was blocking). The sealed Inactive/Local/Remote split is the right shape, and usesRadioConfigSettingsSession catching the Connections -> LoRa direct-entry case is a path I'd missed entirely — good catch. meshPacketFlow collectors no longer survive on unrelated tabs.

Also resolved: the dead default provider is gone (NavigationAssemblyTest now fails loudly instead of exercising a shipped-nowhere path), ModifierMissing is fixed properly with a real modifier param rather than suppressed, entry<SettingsRoute.Settings> takes args again so the crossfade nit is moot, and the new holder tests cover same-instance retention and cross-destination eviction directly.

I also went looking for a stuck overlay in avoid local refresh loading flashensureLoadingForRemote now fires on destNum != null (synchronous) instead of !state.isLocal (async, defaults false), so a remote route sets Loading on the first frame; if loadConfigRoute then early-returned for a non-refreshable module the overlay would never resolve. It doesn't: setResponseStateLoading writes ResponseState.Empty on that path (RadioConfigViewModel.kt:757-760). Not a bug. Worth noting the destNum != null gate does change behavior for remote-administering your own node (destNum == myNodeNum was isLocal, so no overlay; now it shows one) — that reads as a fix to me, since admin round-trips really are in flight there, but it's a deliberate change rather than a no-op refactor.

:feature:settings:allTests, :androidApp:testFdroidDebugUnitTest, and :feature:settings:detekt are green locally.


One non-blocking item, and it's specifically about the comment rather than the behavior:

Observes back-stack mutations without invalidating the app shell for every settings submenu push or pop.

I don't think that holds. produceState's initialValue is an ordinary argument expression, re-evaluated on every recomposition, and it calls settingsRadioConfigSession(backStack.toList()) — so the snapshot read of the backstack still happens on the way in. Because rememberSettingsRadioConfigSession and rememberSettingsRadioConfigViewModelProvider both return values, neither is a restartable composable, so that read is attributed to the nearest restartable ancestor: MainScreen / DesktopMainScreen. Every push and pop should still invalidate the shell.

What produceState + distinctUntilChanged does buy is that the returned State only changes on real session transitions, so holder.activate isn't churning — worth keeping. But the shell invalidation is unchanged from before, just now with an extra coroutine hop that delays activate by a frame (harmless as far as I can tell — I walked the eviction orderings above with the lag in mind).

derivedStateOf would actually deliver what the comment claims, and is simpler and synchronous:

@Composable
internal fun rememberSettingsRadioConfigSession(backStack: NavBackStack<NavKey>) =
    remember(backStack) { derivedStateOf { settingsRadioConfigSession(backStack.toList()) } }

Reads inside a derivedStateOf are tracked by the derived-state object, not the enclosing scope, so the shell only invalidates when the session value actually changes. Worth confirming with a recomposition counter before changing anything — this is a subtle enough corner of the compiler that I'd rather you verify than take my word for it. Either way, please reword the comment to match whichever behavior ships.

Two smaller nits, neither worth another round:

  • remember(entrySession, holder) { holder.ownerFor(entrySession) } mutates stores via getOrPut from inside a remember calculation. An abandoned composition would leave a zero-lease entry behind — self-healing on the next clearUnusedStores(), so it's benign, but it is a side effect in a slot-table calculation.
  • usesRadioConfigSettingsSession derives the ConfigRoute/ModuleRoute cases automatically but hardcodes the four hand-written entries (Settings, DeviceConfiguration, ModuleConfiguration, Administration). A future entry added to settingsGraph has to be mirrored here. The lease keeps a live screen safe if someone forgets, so this is a maintenance note, not a hazard.

Approving.

@jeremiah-k
jeremiah-k marked this pull request as ready for review July 27, 2026 01:35
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
728 1 727 0
View the top 1 failed test(s) by shortest run time
org.meshtastic.app.ui.NavigationAssemblyTest::verifyNavigationGraphsAssembleWithoutCrashing
Stack Traces | 17.4s run time
java.lang.NullPointerException: FirebaseCrashlytics component is not present.
	at com.google.firebase.crashlytics.FirebaseCrashlytics.getInstance(FirebaseCrashlytics.java:197)
	at com.google.firebase.crashlytics.FirebaseCrashlyticsKt.getCrashlytics(FirebaseCrashlytics.kt:27)
	at org.meshtastic.app.analytics.GooglePlatformAnalytics$CrashlyticsLogWriter.log(GooglePlatformAnalytics.kt:299)
	at co.touchlab.kermit.BaseLogger.processLog(BaseLogger.kt:55)
	at org.meshtastic.core.database.DatabaseManager$close$2.invokeSuspend(DatabaseManager.kt:1628)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.internal.ScopeCoroutine.afterResume(Scopes.kt:35)
	at kotlinx.coroutines.AbstractCoroutine.resumeWith(AbstractCoroutine.kt:101)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:47)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:98)
	at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:256)
	at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:54)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlockingImpl(Builders.kt:30)
	at kotlinx.coroutines.BuildersKt.runBlockingImpl(Unknown Source)
	at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK(Builders.concurrent.kt:172)
	at kotlinx.coroutines.BuildersKt.runBlockingK(Unknown Source)
	at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
	at kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
	at org.meshtastic.app.MeshUtilApplication.onTerminate(MeshUtilApplication.kt:152)
	at org.robolectric.shadows.ShadowInstrumentation.runOnMainSyncNoIdle(ShadowInstrumentation.java:1183)
	at org.robolectric.android.internal.AndroidTestEnvironment.tearDownApplication(AndroidTestEnvironment.java:544)
	at org.robolectric.RobolectricTestRunner.afterTest(RobolectricTestRunner.java:330)
	at org.robolectric.internal.SandboxTestRunner.executeInSandbox(SandboxTestRunner.java:500)
	at org.robolectric.internal.SandboxTestRunner.access$900(SandboxTestRunner.java:67)
	at org.robolectric.internal.SandboxTestRunner$7.evaluate(SandboxTestRunner.java:442)
	at org.robolectric.internal.SandboxTestRunner.access$600(SandboxTestRunner.java:67)
	at org.robolectric.internal.SandboxTestRunner$6.evaluate(SandboxTestRunner.java:333)
	at org.robolectric.internal.SandboxTestRunner$3.evaluate(SandboxTestRunner.java:233)
	at org.robolectric.internal.SandboxTestRunner$5.lambda$evaluate$0(SandboxTestRunner.java:317)
	at org.robolectric.internal.bytecode.Sandbox.lambda$runOnMainThread$0(Sandbox.java:101)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614)
	at java.base/java.lang.Thread.run(Thread.java:1474)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@jamesarich
jamesarich merged commit 0030d3b into meshtastic:main Jul 27, 2026
18 checks passed
@jeremiah-k
jeremiah-k deleted the bugfix/settings-navigation-session branch July 28, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag desktop Desktop target

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants