Mobile Development Bug Report

Android 15 edge-to-edge: toolbar and buttons drawn under the system bars

Bumping targetSdk to 35 put our toolbar under the status bar and the Pay button under the navigation bar on Android 15 devices. Edge-to-edge is now enforced, so we handled window insets properly in both Views and Compose screens.

Daniel Tran Daniel Tran @fun_dev · February 25, 2025 · 9 min read · 26 Views
Banner: Android 15 edge-to-edge layout with toolbar and buttons hidden under the system bars

Our parking payments app had to move to targetSdkVersion 35 before Google Play's deadline later in the year, so we did it early, in a quiet sprint. The change was one line in build.gradle.kts. The first internal test build on a Pixel 8 running Android 15 looked broken:

  • The toolbar title sat underneath the status bar clock and battery icons.
  • The "Pay now" button at the bottom of the checkout screen was half covered by the navigation bar on three-button navigation, and fully tappable only by accident on gesture navigation.
  • In the zone map screen, written in Jetpack Compose, the last list item was hidden under the navigation bar and could not be scrolled into view.
  • The status bar had become transparent and white icons on our light toolbar were unreadable.

There was no crash or log message. On an Android 14 device, the same build looked exactly like before.

Reproducing it

  1. Set targetSdk = 35 and install on any device or emulator running Android 15 (API 35).
  2. Open any screen that does not already handle window insets.
  3. Compare with the same APK on Android 14: only Android 15 shows the overlap.

Switching the emulator between gesture navigation and three-button navigation changed how much of the bottom button was covered, which told us the overlap came from system bar insets, not from our layouts changing.

What we ruled out

  • A theme regression. We had not touched themes.xml, and reverting the Material Components update in the same branch did not change anything.
  • A device-specific bug. The same overlap appeared on a Samsung Galaxy S24 with Android 15 and on the API 35 emulator image.
  • Our own enableEdgeToEdge() call. We had never called it. That was the point: the system now did it for us.

Root cause

Android 15 enforces edge-to-edge for apps that target SDK 35. The app window extends behind the status bar and the navigation bar, regardless of whether the app opts in, and Window.setStatusBarColor and setNavigationBarColor no longer have any effect. Our layouts assumed the system would reserve space for the bars, which had been the default since the first Android release. None of our screens applied window insets, so content was drawn under the bars.

What is happening under the hood

The system bars are not part of your layout. When an app draws edge-to-edge, the system reports how much of each edge is covered through WindowInsets. Before Android 15, apps were "fit" to the space between the bars by default and only had to deal with insets if they opted into edge-to-edge. With target SDK 35 on an Android 15 device, the default flips:

Behaviour targetSdk 34 on Android 15 targetSdk 35 on Android 15
Window draws behind status bar no yes
Window draws behind navigation bar no yes
setStatusBarColor() works no effect
Gesture navigation bar opaque or app-coloured transparent
Three-button navigation bar opaque translucent by default

There are several inset types, and picking the right one matters:

  • systemBars() combines status, navigation and caption bars. Use it for padding content.
  • displayCutout() covers notches and camera holes, relevant in landscape.
  • ime() is the on-screen keyboard. Our "Pay now" button also needs to move above it.
  • systemGestures() marks edges reserved for back and home gestures; avoid placing sliders there.

There was a temporary escape hatch, android:windowOptOutEdgeToEdgeEnforcement, a theme attribute that restores the old behaviour on Android 15. Android 16's behaviour changes document it as deprecated and disabled for apps that target API 36, so it only postpones the work by a year.

Environment

Component Version / value
Build Android Gradle Plugin 8.8.0, Kotlin 2.1.10
SDK compileSdk = 35, targetSdk = 35, minSdk = 24
AndroidX activity-ktx 1.10.0, core-ktx 1.15.0, appcompat 1.7.0
UI Views with Material Components 1.12.0, plus Compose BOM 2025.02.00 for two screens
Devices Pixel 8 (Android 15), Galaxy S24 (Android 15), API 35 emulator

Minimal reproduction

<!-- activity_checkout.xml (excerpt): nothing here knows about insets -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <com.google.android.material.appbar.MaterialToolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize" />
    </com.google.android.material.appbar.AppBarLayout>

    <com.google.android.material.button.MaterialButton
        android:id="@+id/payButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:layout_margin="16dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

Who is affected

Every app that sets targetSdk to 35 and runs on Android 15 or later. Apps that target 34 or lower are unaffected on Android 15, which is why many teams only see this when they bump the target SDK for Play's annual requirement. Apps already using enableEdgeToEdge() with proper insets handling see no change.

The fix

1. Opt in explicitly in every activity, so behaviour is the same on Android 14 and lower as on 15. enableEdgeToEdge() from androidx.activity also picks status bar icon colours that contrast with light or dark themes:

class CheckoutActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_checkout)
        applyInsets()
    }
}

2. Views: let the app bar take the top inset, and pad the bottom button. Material's AppBarLayout understands fitsSystemWindows and extends its background under the status bar while keeping the toolbar below it:

  <com.google.android.material.appbar.AppBarLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
+     android:fitsSystemWindows="true">

For the bottom button we combined navigation bar and keyboard insets, keeping the 16dp design margin:

private fun CheckoutActivity.applyInsets() {
    val button = findViewById<MaterialButton>(R.id.payButton)
    val baseMargin = resources.getDimensionPixelSize(R.dimen.space_16)

    ViewCompat.setOnApplyWindowInsetsListener(button) { view, insets ->
        val bars = insets.getInsets(
            WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
        )
        val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
        view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
            leftMargin = baseMargin + bars.left
            rightMargin = baseMargin + bars.right
            bottomMargin = baseMargin + maxOf(bars.bottom, ime.bottom)
        }
        insets
    }
}

We return insets instead of WindowInsetsCompat.CONSUMED because sibling views also need them.

For scrolling lists we padded the RecyclerView and set android:clipToPadding="false", so content can scroll behind the transparent navigation bar but the last item still ends above it.

3. Compose: use the Scaffold's padding and inset-aware modifiers. The zone map list ignored the innerPadding that Material 3's Scaffold provides:

@Composable
fun ZoneListScreen(zones: List<Zone>) {
    Scaffold(
        topBar = { TopAppBar(title = { Text(stringResource(R.string.zones)) }) },
    ) { innerPadding ->
        LazyColumn(
            contentPadding = innerPadding,       // includes status and navigation bar insets
            modifier = Modifier.fillMaxSize(),
        ) {
            items(zones, key = { it.id }) { ZoneRow(it) }
        }
    }
}

On the one custom screen without a Scaffold we used Modifier.safeDrawingPadding() on the root, which combines system bars, display cutout and keyboard.

Alternatives and trade-offs

  • windowOptOutEdgeToEdgeEnforcement in the theme. Gets a release out quickly, but it is deprecated and stops working when you target API 36. We used it for zero builds.
  • fitsSystemWindows="true" on the root of every layout. Quick, but it pads the whole screen, so backgrounds stop at the bars and you lose the edge-to-edge look. It also behaves differently depending on the parent view type. We used it only on AppBarLayout.
  • Hard-coded 24dp or 48dp paddings. Bar heights vary by device, navigation mode and orientation. Never guess; read the insets.

How to test the fix

We tested a matrix rather than one device:

# Toggle three-button and gesture navigation on an API 35 emulator
adb shell cmd overlay enable com.android.internal.systemui.navbar.threebutton
adb shell cmd overlay enable com.android.internal.systemui.navbar.gestural

# Landscape with the display cutout on the left
adb shell settings put system user_rotation 1

Plus an Espresso check that the pay button is fully above the navigation bar:

@Test
fun payButtonIsAboveNavigationBar() {
    ActivityScenario.launch(CheckoutActivity::class.java).onActivity { activity ->
        val button = activity.findViewById<View>(R.id.payButton)
        val insets = ViewCompat.getRootWindowInsets(button)!!
            .getInsets(WindowInsetsCompat.Type.navigationBars())
        val location = IntArray(2).also { button.getLocationOnScreen(it) }
        val screenHeight = activity.window.decorView.height
        assertTrue(location[1] + button.height <= screenHeight - insets.bottom)
    }
}

Confirming in production

  • We rolled out to 10 percent through a staged release. Play Console showed no rise in ANRs or crashes, and the checkout conversion rate on Android 15 devices stayed at 71 percent, matching Android 14.
  • Support tickets mentioning "can't tap pay" or "button hidden", which we had seen sporadically on some foldables even before, dropped to zero over the following month.
  • Screenshots from Firebase Test Lab on 12 device models, including a Galaxy Z Fold, showed no overlap in portrait or landscape.

Regression guard

  • Screenshot tests with Paparazzi for every screen in light and dark themes, with a frame that simulates status and navigation bars.
  • A lint check in our build fails if a theme contains windowOptOutEdgeToEdgeEnforcement.
  • Code review checklist: every new screen must state how it handles top, bottom and keyboard insets.

Security implications

None beyond the bug itself. One user-facing risk is worth noting: a payment confirmation button hidden under system UI can lead to mis-taps on an adjacent element or the home gesture, which matters for screens that confirm money transfers. Edge-to-edge does not change what the app exposes: the status bar can draw over your content but cannot read it, and our existing FLAG_SECURE on the card entry screen still blocks screenshots and recording.

Prevention checklist

  • Call enableEdgeToEdge() in every activity, even before targeting SDK 35.
  • Handle top, bottom, cutout and keyboard insets explicitly on every screen.
  • Use clipToPadding="false" or contentPadding so lists can scroll behind bars and still end above them.
  • Test with both gesture and three-button navigation, and in landscape.
  • Do not ship with windowOptOutEdgeToEdgeEnforcement; it is a temporary attribute.

References

Add a label

#android #edge-to-edge #window-insets #jetpack-compose #targetsdk
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.