From 664c2dac46ced09c7cd876e8442711aae0b75cda Mon Sep 17 00:00:00 2001 From: Moe Hamade <69801237+moehamade@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:42:27 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01MdKgoKZbL5K1wsn1WsES26 --- .../java/com/bitchat/android/MainActivity.kt | 87 +++++++++---------- .../android/navigation/AppNavigator.kt | 11 --- 2 files changed, 43 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index d0047923..8b547f40 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -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 { 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 { diff --git a/app/src/main/java/com/bitchat/android/navigation/AppNavigator.kt b/app/src/main/java/com/bitchat/android/navigation/AppNavigator.kt index 9ce3158c..723b304a 100644 --- a/app/src/main/java/com/bitchat/android/navigation/AppNavigator.kt +++ b/app/src/main/java/com/bitchat/android/navigation/AppNavigator.kt @@ -29,17 +29,6 @@ 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.