refactor(nav): seed the back stack from the host, not from onCreate

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
This commit is contained in:
Moe Hamade 2026-08-26 10:42:27 +03:00
parent 1835281fc9
commit 664c2dac46
2 changed files with 43 additions and 55 deletions

View File

@ -14,7 +14,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
@ -77,6 +76,11 @@ class MainActivity : OrientationAwareActivity() {
@Inject
lateinit var navigator: AppNavigator
// Watches the adapter for the whole Activity, not for as long as some
// composable happens to stay on screen. Scoping it to a destination is what
// let a Bluetooth switch-off go unnoticed once chat became its own route.
private var bluetoothStateReceiver: android.content.BroadcastReceiver? = null
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) {
@ -160,11 +164,6 @@ 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(
@ -179,38 +178,14 @@ class MainActivity : OrientationAwareActivity() {
val onboardingState by mainViewModel.onboardingState.collectAsState()
val root = rootRouteFor(onboardingState)
// Hosted here rather than in the onboarding destination: turning
// Bluetooth off has to be noticed on the chat route too, and a
// destination-scoped receiver is unregistered when its entry leaves
// the back stack.
val context = LocalContext.current
DisposableEffect(context, bluetoothStatusManager) {
val receiver = bluetoothStatusManager.monitorBluetoothState(
context = context,
bluetoothStatusManager = bluetoothStatusManager,
onBluetoothStateChanged = { status ->
if (status == BluetoothStatus.ENABLED &&
mainViewModel.onboardingState.value == OnboardingState.BLUETOOTH_CHECK
) {
checkBluetoothAndProceed()
}
}
)
onDispose {
try {
context.unregisterReceiver(receiver)
} catch (e: IllegalStateException) {
Log.w("BluetoothStatusUI", "Receiver was not registered")
}
}
}
// 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.
// Seeds the stack on its first run and re-roots it on every
// later crossing between onboarding and chat. Keyed on root, so
// it stays quiet between onboarding steps. resetTo rather than
// goTo: onboarding must not be reachable with Back once the app
// is in. Compares the root of the stack, not its top, so pushing
// a destination onto chat does not read as a crossing.
LaunchedEffect(root) {
if (navigator.backStack.lastOrNull() != root) {
if (navigator.backStack.firstOrNull() != root) {
navigator.resetTo(root)
}
}
@ -220,19 +195,27 @@ class MainActivity : OrientationAwareActivity() {
entry<ChatRoute> { ChatScreen(viewModel = chatViewModel) }
}
BitchatNavDisplay(
navigator = navigator,
entryInstallers = setOf(entries),
onExit = { finish() },
modifier = Modifier.fillMaxSize(),
)
// NavDisplay rejects an empty back stack and the effect above does
// not run until after this composition, so the host waits a frame
// for it. Gate on the stack itself: root is non-null immediately,
// so gating on that would compose with nothing to show.
if (navigator.backStack.isNotEmpty()) {
BitchatNavDisplay(
navigator = navigator,
entryInstallers = setOf(entries),
onExit = { finish() },
modifier = Modifier.fillMaxSize(),
)
}
// 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.
// unwind before routes pop. Called unconditionally and gated by
// enabled, because a conditional call would reorder composition.
// Removed once the overlays become routes.
val canHandleBack by chatViewModel.canHandleBack.collectAsState()
BackHandler(
enabled = navigator.backStack.lastOrNull() == ChatRoute && canHandleBack
@ -243,6 +226,18 @@ class MainActivity : OrientationAwareActivity() {
}
}
bluetoothStateReceiver = bluetoothStatusManager.monitorBluetoothState(
context = this,
bluetoothStatusManager = bluetoothStatusManager,
onBluetoothStateChanged = { status ->
if (status == BluetoothStatus.ENABLED &&
mainViewModel.onboardingState.value == OnboardingState.BLUETOOTH_CHECK
) {
checkBluetoothAndProceed()
}
}
)
// Collect state changes in a lifecycle-aware manner
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
@ -902,6 +897,10 @@ class MainActivity : OrientationAwareActivity() {
super.onDestroy()
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
bluetoothStateReceiver?.let {
try { unregisterReceiver(it) } catch (_: IllegalArgumentException) { }
}
bluetoothStateReceiver = null
// Cleanup location status manager
try {

View File

@ -29,17 +29,6 @@ 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.