BackHandler's enabled flag trails the state it is derived from by a
dispatch and a recomposition, so a second quick press after the last
overlay closes still reaches this handler. handleBackPressed then finds
nothing to unwind and returns false, and dropping that result swallowed
the press instead of leaving the chat screen.
The result is now forwarded: pop a route if one is beneath, and otherwise
finish, which is what the press would have done had the handler already
been disabled. This mirrors the guard the callback carried before the
Navigation 3 host replaced it.
Not covered by a test. The condition is a race between input dispatch and
recomposition inside a composed hierarchy, and this project cannot host a
Compose unit test yet: isIncludeAndroidResources fails 24 existing tests
with "targetSdkVersion=37 > maxSdkVersion=35", and pinning Robolectric to
sdk 35 fails 109.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LgmKvwg6yjo1cEmagppK9m
Three changes that all come from the back stack currently being one entry
deep, which hides distinctions this PR is what makes matter.
Seeding moved out of onCreate. It was there because NavDisplay rejects an
empty back stack and effects do not run until after the first composition.
Gating the host on the stack instead lets the effect that re-roots on every
later crossing do the first one too, so there is one mechanism rather than
two. It also stops the start route having to be readable synchronously:
onCreate could only seed at all because onboardingState carries an eager
CHECKING, and anything read from disk later would have had nothing to offer
it. The gate has to test the stack rather than the route, since the route is
non-null from the first composition and would compose the host with nothing
to show.
The crossing guard compares the first entry rather than the last. They are
the same value at depth one, but the effect re-runs on every configuration
change while the retained scope keeps the stack, so once a destination is
pushed onto chat, the top would stop matching the root and every rotation
would silently reset the stack.
The Bluetooth receiver moves to onCreate and onDestroy. Composition and the
Activity cover the same span here, so this is not a lifetime change; it is
that a composition-scoped registration can be narrowed by moving the code,
which is exactly how it came to be scoped to a destination and stopped
firing on the chat route.
setRootIfEmpty goes with the onCreate call it existed for.
Verified on a Pixel 9a: launch without the empty-backstack crash, rotation
both ways with the stack intact, and Bluetooth off then on still logged on
the chat route. 591 unit tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
The ACTION_STATE_CHANGED receiver was registered from a DisposableEffect
inside OnboardingFlowScreen. That was harmless while the when branch kept
that composable on screen for chat too, but chat is its own destination
now, so reaching COMPLETE took the onboarding entry out of composition and
unregistered the only receiver watching the adapter. Turning Bluetooth off
from Quick Settings while sitting in chat then went unnoticed, and the
onResume fallback does not fire without a lifecycle transition.
Reproduced on a Pixel 9a. Disabling Bluetooth on the chat route logged
nothing on this branch, against main:
D BluetoothStatusManager: Bluetooth turned OFF
W MainActivity: Bluetooth disabled or failed: User has turned off their Blue
Hoisted next to NavDisplay, which lives for as long as the Activity's
composition, so it no longer depends on which destination is showing. The
callback also reads onboardingState from the ViewModel rather than the
value captured when the effect ran, which the effect's keys never
invalidated.
Same two lines now log on the chat route, and 591 unit tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
contentModifier already carried the Scaffold's system-bar padding and was
passed to both NavDisplay and the onboarding entry, so onboarding padded
itself twice while ChatScreen, which applies its own status, navigation and
IME insets, gained an extra outer band. Only onboarding takes the scaffold
padding now, matching what the when branch did before this PR, where
ChatScreen was called with no modifier at all.
The verification metadata refresh also adds three kotlinx-coroutines-bom
poms. They resolve through detached configurations that KSP introduces, and
a warm local cache never re-fetches them, so they failed only on a cold CI
runner. Regenerated with --refresh-dependencies so the whole set was
observed at once rather than one CI cycle at a time.
Three components added, none removed, no version drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
The nav3 entries were written only for the debug configurations, because
the locks were refreshed with debug tasks. The root build sets
LockMode.STRICT for every configuration, so resolving releaseCompileClasspath
or releaseRuntimeClasspath rejected them and no release could be built.
Refreshed following docs/reproducible-builds.md: the debug, lint and IDE
copy-lock pass, then bundleRelease and assembleRelease in separate
invocations because the split APK and AAB intermediates cannot coexist.
Also adds com.google.guava:guava-parent:33.6.0-jre. Its pom is resolved
through the KSP classpath that Hilt introduces, and a warm local cache
never re-fetches it, so it only surfaced on CI. The checksum agrees across
the local cache, a fresh Maven Central download, and the .sha256 Central
publishes alongside it.
One component added, none removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
The reason interceptBack exists was written twice, at the call site and
where the parameter is defined. Kept the one next to the definition.
AppRoutes named :feature:<name>:api as the eventual home for the route
types. Those modules do not exist and may not be called that; a comment
that describes a plan rather than the code ages into a wrong one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
Replaces the when (onboardingState) in MainActivity and the ad-hoc back
handling with a Navigation 3 back stack. Navigator, AppNavigator, the
EntryProviderInstaller multibinding and the NavDisplay host live in :app
for now; they move to a :core:navigation module once there is a second
module to justify one.
Onboarding is one destination, not eight. Its steps are driven by
permission results and adapter state changes rather than by the user
navigating, and going back a step has never been supported. Per-step
routes would invent a history that does not exist, so the state machine
stays and only the crossing into chat is a navigation event. That crossing
uses resetTo, so Back cannot re-enter onboarding.
Removes the OnBackPressedCallback that was constructed and registered
inside a composable body, adding a callback on every recomposition of that
branch. NavDisplay owns back now; it drives predictive back through
androidx.navigationevent, and a second handler over the same gesture can
detach the navigationevent input mid-gesture, so there must not be one.
Chat still manages its overlays with booleans, so back consults
ChatViewModel first through interceptBack. That parameter exists only until
those overlays become routes.
The back stack is seeded before setContent rather than from a
LaunchedEffect: NavDisplay rejects an empty back stack and an effect does
not run until after the first composition.
Verified on a Pixel 9a: launch renders without the empty-backstack crash,
chat is reached, Back exits to the launcher instead of re-entering
onboarding, and relaunch is clean. Plus :app:assembleDebug and 591 unit
tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
The Hilt annotations landed mid-block in two files, splitting the androidx and
kotlinx groups those files otherwise keep. No behaviour change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaZivRfLu7FdKjzVwwRjcc
(cherry picked from commit 12b4b2aba288ec8875188d81cd60831764ce51f5)
Replaces the hand-rolled ViewModelProvider.Factory in MainActivity with
constructor injection. No singleton is converted and no behaviour changes;
this only moves ViewModel construction into the graph so feature modules
can obtain their own ViewModels later.
MeshModule bridges the existing process-wide mesh instances into the graph.
Its bindings are deliberately unscoped: MeshServiceHolder is the source of
truth, it is shared with the foreground service, and it supports replacing
the mesh service after a panic clear. A @Singleton binding would pin the
first instance for the process lifetime and hand out a stale service after
a replacement.
ChatViewModel still resolves to a single activity-scoped instance, so the
unifiedMeshService.delegate assignment continues to reference the same
object it did before.
ApkDownloadViewModel is injected through its secondary constructor. Its
collaborators are internal types, and routing those through Dagger's
generated Java buys nothing; the primary constructor stays available so
tests can keep substituting fakes.
Verified: :app:assembleDebug, :wear:assembleDebug, and 591 unit tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaZivRfLu7FdKjzVwwRjcc
(cherry picked from commit 33d6b36e220810c87595851ece7e9a63535c61b0)
KSP and the Dagger/Hilt annotation processors load enough additional classes
into the daemon that a combined testDebugUnitTest + lintDebug run exhausts the
previous 512m ceiling.
The failure is worth describing because it does not name its cause: it surfaces
as an OutOfMemoryError inside ClassLoader.defineClass in whichever task happens
to be loading classes at the time, including lint on an unrelated module. When
it lands during a --write-verification-metadata run it can also truncate
gradle/verification-metadata.xml to zero bytes, which then fails the next build
with "Unable to read dependency verification metadata: Premature end of file".
JVM arguments are not build inputs, so this does not affect reproducibility.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaZivRfLu7FdKjzVwwRjcc
Adds the KSP and Hilt Gradle plugins and the Hilt runtime/compiler
dependencies to :app, with the lock state and verification metadata
regenerated per docs/reproducible-builds.md. No source changes; nothing
is injected yet.
KSP versions independently of Kotlin as of 2.3.0. Earlier releases used a
<kotlin>-<ksp> scheme requiring an exact match to the Kotlin version, so
the catalog records the reason the two versions no longer track.
Verification metadata gains 36 components and removes none. Reviewed:
- androidx.compose.runtime:runtime:1.8.2 and
androidx.annotation:annotation-experimental:1.3.1 are .module metadata
only, with no jar or aar. They are resolution metadata, not artifacts,
and do not downgrade what the Compose BOM selects.
- kotlin-stdlib:2.3.20 carries a jar but the lock state confines it to
kspPluginClasspath, so it never reaches a runtime classpath.
- com.google.dagger:dagger was already on the runtime classpath at 2.59
transitively; it moves to 2.60.1.
- The remainder are the Dagger compiler's own build-time dependencies
(guava, kotlinpoet, javapoet, google-java-format, error_prone).
wear/gradle.lockfile and settings-gradle.lockfile are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaZivRfLu7FdKjzVwwRjcc
(cherry picked from commit a97675b46527f98f5493150b53c926caa5115058)
The block carried seven comment lines over four of code, against 0.17 for
the file as a whole. Most of it restated the identifiers: enabled =
canHandleBack does not need a sentence explaining that Back is intercepted
while there is state to unwind.
What is left is the part that is not derivable. That enabled is a variable
rather than true is a predictive-back decision, and finish() being reachable
at all only makes sense once you know the flag trails the state it mirrors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
BackHandler's enabled flag trails the state it mirrors by a coroutine
dispatch and a recomposition. A second Back press inside that window finds
the handler still enabled while handleBackPressed() already has nothing to
unwind, and ignoring its result consumed the press instead of letting the
system act on it. The callback the handler replaced did forward it.
Falls back to finish() on an unhandled press, which is what the dispatcher
reached before once no enabled callback consumed it.
Reported by Codex review on #912.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
The chat branch of OnboardingFlowScreen built an OnBackPressedCallback and
called addCallback(this, ...) from inside a composable body. Composable
bodies re-run on recomposition, and that function observes eight
MainViewModel StateFlows, so every Bluetooth, location, or loading change
registered another callback. They were bound to the activity, so none were
released until onDestroy.
Each accumulated callback also multiplied the work of an unhandled Back
press: the callback disabled itself, re-dispatched, and the next one down
consulted ChatViewModel again. Five recompositions meant one Back press
consulted it five times before reaching the system.
BackHandler keeps a single registration across recompositions and disposes
it when the branch leaves composition. Driving its enabled flag from
ChatState.canHandleBack also retires the disable/re-dispatch/re-enable
dance: with nothing to unwind the handler is simply disabled, so the
dispatcher falls through to the system.
canHandleBack mirrors the branches of ChatViewModel.handleBackPressed and
is covered by unit tests, since the handler being enabled and the press
being consumed have to agree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26
If SQLite clearAll fails mid-panic, in-memory state was already cleared
but a process restart could reload encrypted history from disk (#699).
Fall back to deleting the database files before reporting failure.