The navigation bar appearing while a popup is open is not necessarily the bug. The popup lives in another window and temporarily changes focus; Android may expose system controls so the user is never trapped. The bug is letting the activity forget its intended fullscreen state after that temporary window goes away.

First decide whether immersive mode belongs here

  • Use immersive mode for experiences such as full-screen video, games, presentations, kiosk-like flows under appropriate device management, or spatial content.

  • Ordinary forms, lists, settings, and productivity screens should normally keep system navigation discoverable.

  • Users must retain a reliable way to reveal and use system bars.

  • Accessibility services, gesture navigation, multi-window, keyboards, and cutouts remain part of the design.

  • Edge-to-edge layout is not the same as hiding system bars.

1. Lay content edge to edge

FullscreenActivity.kt (onCreate)kotlin
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, false)
    setContentView(R.layout.activity_fullscreen)
 
    ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root)) { view, insets ->
        val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
        view.updatePadding(
            left = systemBars.left,
            top = systemBars.top,
            right = systemBars.right,
            bottom = systemBars.bottom,
        )
        insets
    }
}

Edge-to-edge controls layout, not visibility

  • setDecorFitsSystemWindows(false) allows content to draw behind system bar areas.

  • The insets listener keeps important controls away from cutouts and tappable system regions while bars are visible.

  • A video surface/background may extend edge to edge while buttons receive padding.

  • Applying the same padding repeatedly must be idempotent; avoid accumulating prior padding.

  • Android 15 edge-to-edge enforcement for apps targeting newer SDKs makes correct inset handling essential even when bars are shown.

2. Centralize immersive state

FullscreenActivity.kt (system bars)kotlin
private var wantsImmersive = true
 
private fun applySystemBarState() {
    val controller = WindowCompat.getInsetsController(window, window.decorView)
 
    if (wantsImmersive) {
        controller.systemBarsBehavior =
            WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
        controller.hide(WindowInsetsCompat.Type.systemBars())
    } else {
        controller.show(WindowInsetsCompat.Type.systemBars())
    }
}

One function owns show and hide

  • WindowCompat.getInsetsController() provides a compatible controller across supported AndroidX levels.

  • Type.systemBars() includes status and navigation bars.

  • Transient-by-swipe lets users reveal bars temporarily with system gestures.

  • The Boolean is product/UI intent—not a report of whether bars are currently visible.

  • Calling the function repeatedly converges on the desired state instead of toggling blindly.

  • Set wantsImmersive=false when the user exits fullscreen or the screen no longer qualifies.

3. Apply after the activity is ready

FullscreenActivity.kt (lifecycle)kotlin
override fun onPostResume() {
    super.onPostResume()
    applySystemBarState()
}
 
override fun onWindowFocusChanged(hasFocus: Boolean) {
    super.onWindowFocusChanged(hasFocus)
    if (hasFocus && wantsImmersive) {
        window.decorView.post { applySystemBarState() }
    }
}

Focus restoration solves the popup case

  • onPostResume() restores the intended state after lifecycle return.

  • A PopupMenu/dialog can make the activity window lose focus temporarily.

  • When focus returns, posting lets the popup/window transition settle before applying bars.

  • The focus callback does nothing while focus is absent and does not hide bars over another window.

  • The policy flag prevents re-entering fullscreen after the user intentionally exits it.

  • Avoid arbitrary long delays; react to the real focus/dismiss event.

4. Reapply explicitly when PopupMenu dismisses

FullscreenActivity.kt (PopupMenu)kotlin
private fun showOverflow(anchor: View) {
    PopupMenu(this, anchor).apply {
        menuInflater.inflate(R.menu.player_overflow, menu)
        setOnMenuItemClickListener(::onOverflowItemSelected)
        setOnDismissListener {
            if (wantsImmersive) {
                window.decorView.post { applySystemBarState() }
            }
        }
        show()
    }
}

Dismissal is more precise than polling

  • The popup remains usable while Android shows whatever system UI it requires.

  • The dismiss listener handles both selection and outside/back dismissal.

  • Posting waits until popup teardown/focus restoration reaches the message queue.

  • onWindowFocusChanged() remains a general fallback for dialogs/other windows.

  • Keep menu selection behavior separate from system-bar restoration.

  • If a menu action leaves fullscreen, set wantsImmersive=false before dismissal.

5. Handle a user fullscreen toggle

FullscreenActivity.kt (toggle)kotlin
private fun setImmersiveEnabled(enabled: Boolean) {
    wantsImmersive = enabled
    applySystemBarState()
 
    fullscreenButton.contentDescription = getString(
        if (enabled) R.string.exit_fullscreen else R.string.enter_fullscreen,
    )
}

Fullscreen is a user-visible state

  • Update the policy and bars together.

  • The control label describes the action that will occur next.

  • Persist the preference only when that behavior makes sense across recreation/new content.

  • Do not automatically re-enter after a user deliberately reveals/exits fullscreen unless the experience clearly promises sticky immersive behavior.

  • Provide keyboard/controller/accessibility activation as required by the form factor.

Deprecated implementation to remove

Legacy code (do not add)kotlin
window.decorView.systemUiVisibility = (
    View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
        View.SYSTEM_UI_FLAG_FULLSCREEN or
        View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
        View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
        View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
        View.SYSTEM_UI_FLAG_LAYOUT_STABLE
)

Why flags are no longer the right abstraction

  • systemUiVisibility and related flags are deprecated.

  • Bitmasks mix layout, visibility, and behavior in fragile combinations.

  • Insets controllers express bar types and behavior explicitly.

  • Insets listeners handle content placement when bars/cutouts/IME change.

  • Do not run legacy and modern controllers simultaneously; they can overwrite each other.

System bars and IME are different inset types

  • systemBars() covers status/navigation bars.

  • ime() represents the on-screen keyboard.

  • Opening a text field/menu/dialog may require IME and bars for interaction.

  • Do not hide IME as a side effect of restoring immersive bars unless the user/action explicitly dismisses it.

  • Use WindowInsets animation/listeners for layouts that respond smoothly to keyboard appearance.

  • Test hardware keyboard, software keyboard, rotation, and focus restoration.

Dialogs, sheets, and permission prompts

  • Do not hide system bars while a permission/system dialog owns focus.

  • Restore only after activity focus returns and the experience still wants fullscreen.

  • A dialog-themed Activity has its own window/controller.

  • Bottom sheets anchored near gesture areas need navigation-bar insets.

  • System prompts may ignore app bar requests by design.

  • Never use immersive mode to obscure consent, permissions, safety, or payment UI.

Compose uses the same platform concepts

A Compose screen still runs in an Activity window. Keep fullscreen intent in stable state, invoke the controller from lifecycle-aware effects, and apply WindowInsets/safe drawing padding to controls. Do not call hide on every recomposition. Popup/dropdown implementations may use separate windows and deserve the same dismissal/focus restoration test.

Listen to actual bar visibility when UI needs it

FullscreenActivity.kt (visibility observation)kotlin
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { _, insets ->
    val barsVisible = insets.isVisible(WindowInsetsCompat.Type.systemBars())
    fullscreenButton.isSelected = !barsVisible
    insets
}

Observed visibility is not desired policy

  • Insets report whether bars are currently visible.

  • Transient swipe can show bars even while wantsImmersive remains true.

  • Use observed state for icons/analytics/layout, not to immediately counter every user reveal.

  • Return the insets so downstream dispatch continues.

  • Avoid installing conflicting listeners on the same view; compose inset handling centrally.

  • Gesture navigation uses edge gestures and transient indicators.

  • Three-button navigation has a persistent/tappable bar that immersive mode can hide transiently.

  • Two-button/older OEM modes may behave differently.

  • Do not place critical controls in back/home gesture exclusion areas.

  • Gesture exclusion is tightly constrained and should be minimal.

  • Test Back/predictive back, Home, Recents, rotation, and task switching under every supported mode.

Multi-window, PiP, and freeform boundaries

  • Immersive requests may be ignored or behave differently in multi-window.

  • Do not force fullscreen while the Activity is in picture-in-picture.

  • Window size changes require responsive layouts, not assumptions based on hidden bars.

  • Foldables/desktops/external displays can have different inset and navigation behavior.

  • Re-evaluate wantsImmersive when windowing mode/product context changes.

Accessibility and escape routes

  • TalkBack users need labeled, reachable controls and predictable focus.

  • Do not rely on an invisible edge gesture as the only way to exit fullscreen.

  • Support keyboard Escape/Back/controller buttons where appropriate.

  • Respect magnification, switch access, large text, display scaling, and high contrast.

  • Transient bar reveals should not cause destructive layout jumps or move the focused control.

  • Kiosk/lock-task mode is a separate managed-device feature; immersive mode is not a security boundary.

Test the PopupMenu regression

  1. Enter the qualifying fullscreen experience.

  2. Open PopupMenu and confirm menu items, system gestures, and accessibility remain usable.

  3. Dismiss by selection, outside tap, and Back.

  4. Verify focus returns and bars settle to intended state without flicker/loop.

  5. Choose an item that intentionally exits fullscreen and verify bars remain visible.

  6. Repeat rapidly and after rotation, pause/resume, IME, dialog, task switch, and configuration change.

  7. Repeat under gesture and three-button navigation on supported API/OEM devices.

  • If an item launches settings, a picker, permission flow, or another Activity, do not re-hide bars while that window owns focus.

  • Keep desired fullscreen state, but apply it only when the original Activity resumes and regains focus.

  • If the action semantically leaves the fullscreen experience, clear the desired state before launch.

  • Test canceled and completed results because return timing/focus sequences can differ.

  • Ensure no dismissal callback races the external-window launch and produces a visible bar flash.

Inspect insets and focus through diagnostics

Authorized test devicebash
adb logcat -c
# Exercise fullscreen and PopupMenu, then inspect app-tagged focus/insets logs.
adb logcat -d | rg "FullscreenActivity|WindowInsets|hasFocus"
Record focus loss, popup dismissal, focus return, and the single reapply event.

Log transitions, not user content

  • Add temporary structured logs for desired state, focus, observed bar visibility, and dismissal.

  • Do not log menu text if it can contain user/private data.

  • Clearing Logcat removes history; use a dedicated test device when acceptable.

  • Remove noisy logs before release or route them through privacy-safe telemetry.

  • A screen recording plus timestamps helps diagnose one-frame flicker that logs miss.

Common failures decoded

  • Bars stay visible after dismiss: activity never reapplies desired state after focus/dismissal.

  • Popup immediately closes/flickers: hide is being called while popup owns focus or in a loop.

  • Bars cannot be revealed: behavior/user gestures are being fought; use transient-by-swipe and test navigation mode.

  • Content jumps when bars appear: edge-to-edge/inset padding is incomplete or accumulated incorrectly.

  • Keyboard vanishes: app conflated system bars with IME or cleared focus.

  • Fullscreen returns after user exited: policy Boolean was not updated before reapply.

  • Only old devices work: modern and deprecated APIs are mixed or AndroidX versions/config differ.

  • Only one OEM fails: test system gesture/window behavior; app requests are not absolute commands.

  • Compose loops: controller calls run on every recomposition instead of state/lifecycle transitions.

Production completion checklist

  • Immersive mode is justified for this experience and has a visible/accessible exit.

  • WindowInsetsControllerCompat is the only bar-visibility owner; deprecated flags are removed.

  • Edge-to-edge layout applies insets to critical controls without cumulative padding.

  • One explicit state records whether fullscreen remains desired.

  • Bars are reapplied only after focus/dismiss/lifecycle transitions.

  • Popup selection can intentionally disable fullscreen before dismissal.

  • IME, dialogs, permissions, multi-window, PiP, rotation, foldables, and Android 15 edge-to-edge behavior are tested.

  • Gesture/three-button navigation, Back/Home/Recents, TalkBack, keyboard/controller, zoom/scaling, and transient reveal pass.

  • No loop fights system UI and telemetry shows no focus/reapply storm.

Official Android references