feat(nav): put onboarding and chat behind a Navigation 3 back stack

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
This commit is contained in:
Moe Hamade 2026-08-26 00:10:27 +03:00
parent 480dc38333
commit c7d551e2af
11 changed files with 366 additions and 16 deletions

View File

@ -198,6 +198,10 @@ dependencies {
implementation(libs.androidx.exifinterface)
// Testing
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
testImplementation(libs.bundles.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.bundles.compose.testing)

View File

@ -116,21 +116,31 @@ androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileCla
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-navigation3-android:2.11.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-navigation3:2.11.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation3:navigation3-runtime-android:1.1.6=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.navigation3:navigation3-runtime:1.1.6=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.navigation3:navigation3-ui-android:1.1.6=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.navigation3:navigation3-ui:1.1.6=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-compose:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-runtime-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-runtime:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-android:1.1.2=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose-android:1.1.2=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose:1.1.2=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent:1.1.2=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath
androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath

View File

@ -44,8 +44,15 @@ import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.navigation.AppNavigator
import com.bitchat.android.navigation.BitchatNavDisplay
import com.bitchat.android.navigation.ChatRoute
import com.bitchat.android.navigation.EntryProviderInstaller
import com.bitchat.android.navigation.OnboardingRoute
import com.bitchat.android.navigation.rootRouteFor
import com.bitchat.android.services.VerificationService
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -65,6 +72,11 @@ class MainActivity : OrientationAwareActivity() {
private var pendingMeshForegroundServiceStart = false
private val chatViewModel: ChatViewModel by viewModels()
// Held by ActivityRetainedComponent, so the back stack outlives configuration
// changes without being rebuilt here.
@Inject
lateinit var navigator: AppNavigator
private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
@ -148,16 +160,57 @@ class MainActivity : OrientationAwareActivity() {
onOnboardingFailed = ::handleOnboardingFailed
)
// Seed the stack before the first composition. NavDisplay rejects an empty
// back stack, and a LaunchedEffect would not run until after it has already
// composed once.
navigator.setRootIfEmpty(rootRouteFor(mainViewModel.onboardingState.value))
setContent {
BitchatTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background
) { innerPadding ->
OnboardingFlowScreen(modifier = Modifier
val contentModifier = Modifier
.fillMaxSize()
.padding(innerPadding)
val onboardingState by mainViewModel.onboardingState.collectAsState()
val root = rootRouteFor(onboardingState)
// Keyed on root, so this fires only when the app crosses between
// onboarding and chat — not on every step within onboarding.
// resetTo rather than goTo: onboarding must not be reachable with
// Back once the app is in.
LaunchedEffect(root) {
if (navigator.backStack.lastOrNull() != root) {
navigator.resetTo(root)
}
}
val entries: EntryProviderInstaller = {
entry<OnboardingRoute> { OnboardingFlowScreen(contentModifier) }
entry<ChatRoute> { ChatScreen(viewModel = chatViewModel) }
}
BitchatNavDisplay(
navigator = navigator,
entryInstallers = setOf(entries),
onExit = { finish() },
modifier = contentModifier,
)
// Chat still drives its overlays with booleans rather than routes,
// and NavDisplay enables its own handler only while something sits
// beneath the current scene, which at the root destination is never.
// So the press has to be claimed here. Composed after NavDisplay:
// among enabled handlers the last one composed wins, so overlays
// unwind before routes pop. Removed once the overlays become routes.
val canHandleBack by chatViewModel.canHandleBack.collectAsState()
BackHandler(
enabled = navigator.backStack.lastOrNull() == ChatRoute && canHandleBack
) {
chatViewModel.handleBackPressed()
}
}
}
}
@ -306,17 +359,12 @@ class MainActivity : OrientationAwareActivity() {
)
}
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
val canHandleBack by chatViewModel.canHandleBack.collectAsState()
// Disabled rather than always-on so predictive back can preview
// the exit instead of the app claiming every gesture.
BackHandler(enabled = canHandleBack) {
// enabled trails the state by a dispatch and a recomposition.
if (!chatViewModel.handleBackPressed()) finish()
}
ChatScreen(viewModel = chatViewModel)
}
// CHECKING, INITIALIZING and COMPLETE are handled by ChatRoute, so this
// composable is only ever shown for the onboarding steps themselves.
OnboardingState.CHECKING,
OnboardingState.INITIALIZING,
OnboardingState.COMPLETE -> Unit
OnboardingState.ERROR -> {
InitializationErrorScreen(
modifier = modifier,

View File

@ -0,0 +1,76 @@
package com.bitchat.android.navigation
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.navigation3.runtime.NavKey
import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject
/**
* Single-stack navigator backed by a [SnapshotStateList] that NavDisplay observes.
*
* Scoped to [dagger.hilt.android.components.ActivityRetainedComponent], which is
* what makes the back stack survive configuration changes — there is no
* rememberNavBackStack here, and so no requirement that keys be serializable.
*
* The stack is *not* restored across process death. Nothing needs that yet:
* onboarding re-derives its state on launch. When it is needed, the move is
* kotlinx-serialization plus rememberNavBackStack, which is why keys are kept as
* plain data objects that would be trivial to annotate.
*
* Deliberately single-stack. The multi-back-stack pattern exists to serve bottom
* navigation; this app has no tabs, so it would be a map that only ever holds
* one key.
*/
@Stable
@ActivityRetainedScoped
class AppNavigator @Inject constructor() : Navigator {
val backStack: SnapshotStateList<NavKey> = mutableStateListOf()
/**
* Seeds the stack with [root] the first time only.
*
* Idempotent by design: an Activity recreated after a configuration change
* re-runs its setup, and the retained scope still holds the real history.
* Seeding again would discard it.
*/
fun setRootIfEmpty(root: NavKey) {
if (backStack.isEmpty()) backStack.add(root)
}
override fun goTo(dest: NavKey) {
// A same-frame double tap pushes the same key twice. Dedup at the source
// rather than guarding every call site.
if (backStack.lastOrNull() != dest) backStack.add(dest)
}
override fun goBack(): Boolean {
if (backStack.size <= 1) return false
backStack.removeAt(backStack.lastIndex)
return true
}
override fun popTo(route: NavKey, inclusive: Boolean): Boolean {
val index = backStack.indexOfLast { it == route }
if (index < 0) return false
val target = if (inclusive) index else index + 1
// Already at the requested route with inclusive = false pops nothing and
// still succeeded.
while (backStack.size > target) {
backStack.removeAt(backStack.lastIndex)
}
return true
}
override fun replaceCurrent(dest: NavKey) {
if (backStack.isNotEmpty()) backStack.removeAt(backStack.lastIndex)
backStack.add(dest)
}
override fun resetTo(dest: NavKey) {
backStack.clear()
backStack.add(dest)
}
}

View File

@ -0,0 +1,35 @@
package com.bitchat.android.navigation
import androidx.navigation3.runtime.NavKey
import com.bitchat.android.onboarding.OnboardingState
/**
* Top-level destinations.
*
* Onboarding is one destination, not eight. Its steps are driven by permission
* results and adapter state changes rather than by the user navigating, and the
* app has never supported going back from one step to the previous one. Giving
* each step its own entry would invent a history that does not exist.
*
* These live in :app because both destinations still need collaborators the
* Activity owns. They move to :feature:<name>:api as those features become
* modules.
*/
data object OnboardingRoute : NavKey
data object ChatRoute : NavKey
/**
* The destination that should be at the root for a given onboarding state.
*
* CHECKING and INITIALIZING map to chat, matching the behaviour this replaced:
* the app shows the chat screen while it verifies its own readiness rather than
* flashing an onboarding step.
*/
fun rootRouteFor(state: OnboardingState): NavKey = when (state) {
OnboardingState.CHECKING,
OnboardingState.INITIALIZING,
OnboardingState.COMPLETE -> ChatRoute
else -> OnboardingRoute
}

View File

@ -0,0 +1,44 @@
package com.bitchat.android.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
/**
* Hosts every feature's destinations.
*
* [entryInstallers] arrives as a Hilt multibinding, so adding a destination
* means adding an @IntoSet provider in the owning feature — this function never
* changes.
*
* This handles Back only between destinations, and only while there is one to
* go back to: NavDisplay enables its handler on scene.previousEntries being
* non-empty, so at the root destination it takes no press at all. A screen with
* state the back stack does not model has to claim Back itself, with a handler
* registered into the same dispatcher — androidx.activity.compose's BackHandler
* is, since activity-compose prefers the NavigationEventDispatcher when one is
* present. Among enabled handlers the last one composed wins.
*/
@Composable
fun BitchatNavDisplay(
navigator: AppNavigator,
entryInstallers: Set<EntryProviderInstaller>,
onExit: () -> Unit,
modifier: Modifier = Modifier,
) {
NavDisplay(
backStack = navigator.backStack,
modifier = modifier,
onBack = { if (!navigator.goBack()) onExit() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
entryInstallers.forEach { install -> install() }
},
)
}

View File

@ -0,0 +1,12 @@
package com.bitchat.android.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
/**
* One feature's contribution to the navigation graph.
*
* Features provide these into a Set via Hilt multibindings, so the host
* assembles every destination without importing any feature's internals.
*/
typealias EntryProviderInstaller = EntryProviderScope<NavKey>.() -> Unit

View File

@ -0,0 +1,18 @@
package com.bitchat.android.navigation
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityRetainedComponent
@Module
@InstallIn(ActivityRetainedComponent::class)
abstract class NavigationModule {
/**
* Features inject [Navigator]; only the host injects [AppNavigator] itself,
* since reading or seeding the back stack is the host's job.
*/
@Binds
abstract fun bindNavigator(impl: AppNavigator): Navigator
}

View File

@ -0,0 +1,27 @@
package com.bitchat.android.navigation
import androidx.navigation3.runtime.NavKey
/**
* The navigation surface features are allowed to depend on.
*
* Features call this rather than touching the back stack directly, so the
* guards below live in one place instead of at every call site.
*/
interface Navigator {
/** Push [dest], unless it is already on top. */
fun goTo(dest: NavKey)
/** Pop one entry. Returns false when the stack cannot go back any further. */
fun goBack(): Boolean
/** Pop back to [route]. Returns false when [route] is not on the stack. */
fun popTo(route: NavKey, inclusive: Boolean = false): Boolean
/** Swap the current top for [dest], so Back skips the screen being replaced. */
fun replaceCurrent(dest: NavKey)
/** Clear the stack and start again at [dest]. */
fun resetTo(dest: NavKey)
}

View File

@ -22,6 +22,9 @@ compose-icons-extended = "1.7.8"
# Navigation
navigation-compose = "2.9.8"
# Latest stable line; the 1.2.0 branch is still alpha. 1.1.6 floors Compose at
# 1.10.0, below the 1.11.4 the BOM resolves, so it does not drag Compose forward.
navigation3 = "1.1.6"
# Dependency injection
hilt = "2.60.1"
@ -83,6 +86,11 @@ mockito-core = "5.23.0"
roboelectric = "4.15" # 4.16+ drops the AndroidKeyStore shim; breaks EncryptionServiceTest
[libraries]
# Gradle plugin artifacts, resolved only by build-logic convention plugins
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
# AndroidX Core
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime" }
@ -104,6 +112,9 @@ androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-
# Navigation
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle-runtime" }
# Dependency injection
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }

View File

@ -5465,5 +5465,70 @@
<sha256 value="e154a3231cd6cde68aabf274fd18964d49a9c1a5f5e9ed3bf471e9ae7bdf08e6" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-navigation3" version="2.11.0">
<artifact name="lifecycle-viewmodel-navigation3-2.11.0.module">
<sha256 value="8a4771ae287872a2439033b4b39ab29648989bcdd766d5a381798890b9f0a6b1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-navigation3-android" version="2.11.0">
<artifact name="lifecycle-viewmodel-navigation3-android-2.11.0.module">
<sha256 value="4b4ef039749a6e0ac8a586c0cfff40c1b07e448142e0c48d28e02c704b065247" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-navigation3.aar">
<sha256 value="07889cc98a311023c49cff1feabb23adadcf72121e327e7b9431f1d4831a5ab9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigation3" name="navigation3-runtime" version="1.1.6">
<artifact name="navigation3-runtime-1.1.6.module">
<sha256 value="2fd41f2626eb17ef5690f8886f40d430459cbe9822359637bb027545dd900c5f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigation3" name="navigation3-runtime-android" version="1.1.6">
<artifact name="navigation3-runtime-android-1.1.6.module">
<sha256 value="294a003e4280a3cd0e8ed24ec605aec6b98be5ee378bc0f2ef0ebe7ceac79487" origin="Generated by Gradle"/>
</artifact>
<artifact name="navigation3-runtime.aar">
<sha256 value="82145fb70f9c7fd6681e54ed2c9b61787ef428ec62a59d4200a6746394f00336" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigation3" name="navigation3-ui" version="1.1.6">
<artifact name="navigation3-ui-1.1.6.module">
<sha256 value="cc9c3c03f97b10bc8a019f56f01e1cfd668b1753823ca4b4c24357730d4d10d4" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigation3" name="navigation3-ui-android" version="1.1.6">
<artifact name="navigation3-ui-android-1.1.6.module">
<sha256 value="e3b0f7fe9c614dd63ae3fdca51ee962f93136fe3216d87fd42767c91084100b3" origin="Generated by Gradle"/>
</artifact>
<artifact name="navigation3-ui.aar">
<sha256 value="1b7004e94f69be0e264ee983a0fbde908e74fb5464f976f2a8b5a9444c414f83" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigationevent" name="navigationevent" version="1.1.2">
<artifact name="navigationevent-1.1.2.module">
<sha256 value="9e8c5ad1c7f80dfec824b7ebe219654888515a82bc26e0d2cc679b70b350ffe9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigationevent" name="navigationevent-android" version="1.1.2">
<artifact name="navigationevent-android-1.1.2.module">
<sha256 value="44c6081542bea72773dea5733e5cb9c0cdfab616985c4bcebe087959ee4a93ea" origin="Generated by Gradle"/>
</artifact>
<artifact name="navigationevent.aar">
<sha256 value="40a357103ab703762bf434f424a8486be18652b4aa25110f0d635a4cef5dcd63" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigationevent" name="navigationevent-compose" version="1.1.2">
<artifact name="navigationevent-compose-1.1.2.module">
<sha256 value="3736c69a73fe2b7389adb05529dfe15e9808eb1e1106cfbc2d5525bc15a1f1d3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.navigationevent" name="navigationevent-compose-android" version="1.1.2">
<artifact name="navigationevent-compose-android-1.1.2.module">
<sha256 value="24a0d539aaa2be6812cfa07cb309a6310538f51a4a5419448b759ed4993f6d98" origin="Generated by Gradle"/>
</artifact>
<artifact name="navigationevent-compose.aar">
<sha256 value="ac9773dc9076d3b035ccf5494316d78a5f9abc18a8fa73fb211f92957286e1aa" origin="Generated by Gradle"/>
</artifact>
</component>
</components>
</verification-metadata>