From c7d551e2aff4a77894b15678d537523e6f527018 Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:10:27 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26 --- app/build.gradle.kts | 4 + app/gradle.lockfile | 18 ++++- .../java/com/bitchat/android/MainActivity.kt | 72 +++++++++++++++--- .../android/navigation/AppNavigator.kt | 76 +++++++++++++++++++ .../bitchat/android/navigation/AppRoutes.kt | 35 +++++++++ .../android/navigation/BitchatNavDisplay.kt | 44 +++++++++++ .../navigation/EntryProviderInstaller.kt | 12 +++ .../android/navigation/NavigationModule.kt | 18 +++++ .../bitchat/android/navigation/Navigator.kt | 27 +++++++ gradle/libs.versions.toml | 11 +++ gradle/verification-metadata.xml | 65 ++++++++++++++++ 11 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/navigation/AppNavigator.kt create mode 100644 app/src/main/java/com/bitchat/android/navigation/AppRoutes.kt create mode 100644 app/src/main/java/com/bitchat/android/navigation/BitchatNavDisplay.kt create mode 100644 app/src/main/java/com/bitchat/android/navigation/EntryProviderInstaller.kt create mode 100644 app/src/main/java/com/bitchat/android/navigation/NavigationModule.kt create mode 100644 app/src/main/java/com/bitchat/android/navigation/Navigator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 939c1b33..81649153 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/gradle.lockfile b/app/gradle.lockfile index 21a4ec95..62b09af7 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -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 diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 41d95a1e..280e7d5a 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -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 { OnboardingFlowScreen(contentModifier) } + entry { 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, diff --git a/app/src/main/java/com/bitchat/android/navigation/AppNavigator.kt b/app/src/main/java/com/bitchat/android/navigation/AppNavigator.kt new file mode 100644 index 00000000..9ce3158c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/navigation/AppNavigator.kt @@ -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 = 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) + } +} diff --git a/app/src/main/java/com/bitchat/android/navigation/AppRoutes.kt b/app/src/main/java/com/bitchat/android/navigation/AppRoutes.kt new file mode 100644 index 00000000..0eae3b97 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/navigation/AppRoutes.kt @@ -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::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 +} diff --git a/app/src/main/java/com/bitchat/android/navigation/BitchatNavDisplay.kt b/app/src/main/java/com/bitchat/android/navigation/BitchatNavDisplay.kt new file mode 100644 index 00000000..c0009527 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/navigation/BitchatNavDisplay.kt @@ -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, + 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() } + }, + ) +} diff --git a/app/src/main/java/com/bitchat/android/navigation/EntryProviderInstaller.kt b/app/src/main/java/com/bitchat/android/navigation/EntryProviderInstaller.kt new file mode 100644 index 00000000..18d86803 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/navigation/EntryProviderInstaller.kt @@ -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.() -> Unit diff --git a/app/src/main/java/com/bitchat/android/navigation/NavigationModule.kt b/app/src/main/java/com/bitchat/android/navigation/NavigationModule.kt new file mode 100644 index 00000000..39895808 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/navigation/NavigationModule.kt @@ -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 +} diff --git a/app/src/main/java/com/bitchat/android/navigation/Navigator.kt b/app/src/main/java/com/bitchat/android/navigation/Navigator.kt new file mode 100644 index 00000000..1c66b2bb --- /dev/null +++ b/app/src/main/java/com/bitchat/android/navigation/Navigator.kt @@ -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) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92034c89..1034a2f1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8685436f..9066a436 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -5465,5 +5465,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +