From 20712a647e15c10abd439be9d5d766ff9a88761e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 11:21:56 +0300 Subject: [PATCH 1/2] fix: ignore a system bar hidden through the system UI visibility on Android 6-10 On API 23-29 a bar hidden through SYSTEM_UI_FLAG_FULLSCREEN or SYSTEM_UI_FLAG_HIDE_NAVIGATION keeps its system window inset under SYSTEM_UI_FLAG_LAYOUT_STABLE, so a hidden status bar still counted as the top inset and a hidden navigation bar as the bottom one. A hidden bar now leaves only the display cutout, as getInsets does from API 30. --- android/build.gradle | 4 + .../safeareacontext/SafeAreaUtils.kt | 35 +++++- .../safeareacontext/SafeAreaUtilsTest.kt | 118 ++++++++++++++++++ example/android/gradle.properties | 2 +- package.json | 1 + scripts/check-package.mjs | 3 +- 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 android/src/test/java/com/th3rdwave/safeareacontext/SafeAreaUtilsTest.kt diff --git a/android/build.gradle b/android/build.gradle index b15bdc58..4f7b0a13 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -132,4 +132,8 @@ def kotlin_version = getExtOrDefault('kotlinVersion', project.properties['RNSAC_ dependencies { implementation 'com.facebook.react:react-native:+' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + testImplementation "junit:junit:4.13.2" + testImplementation "org.mockito.kotlin:mockito-kotlin:6.4.0" + testImplementation "org.robolectric:robolectric:4.17" } diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaUtils.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaUtils.kt index 9dd9eeac..d7420882 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaUtils.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaUtils.kt @@ -26,20 +26,47 @@ private fun getRootWindowInsetsCompatR(rootView: View): EdgeInsets? { ) } +@RequiresApi(Build.VERSION_CODES.P) +private fun getDisplayCutoutInsetsCompatP(insets: WindowInsets): EdgeInsets { + val cutout = + insets.displayCutout ?: return EdgeInsets(top = 0f, right = 0f, bottom = 0f, left = 0f) + return EdgeInsets( + top = cutout.safeInsetTop.toFloat(), + right = cutout.safeInsetRight.toFloat(), + bottom = cutout.safeInsetBottom.toFloat(), + left = cutout.safeInsetLeft.toFloat(), + ) +} + +private fun getDisplayCutoutInsetsCompat(insets: WindowInsets): EdgeInsets { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> getDisplayCutoutInsetsCompatP(insets) + else -> EdgeInsets(top = 0f, right = 0f, bottom = 0f, left = 0f) + } +} + @RequiresApi(Build.VERSION_CODES.M) @Suppress("DEPRECATION") private fun getRootWindowInsetsCompatM(rootView: View): EdgeInsets? { val insets = rootView.rootWindowInsets ?: return null + // Under SYSTEM_UI_FLAG_LAYOUT_STABLE a hidden bar keeps its system window inset, so a bar hidden + // through the system UI visibility leaves only the display cutout, as getInsets does from API 30. + val systemUiVisibility = rootView.windowSystemUiVisibility + val isStatusBarHidden = (systemUiVisibility and View.SYSTEM_UI_FLAG_FULLSCREEN) != 0 + val isNavigationBarHidden = (systemUiVisibility and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) != 0 + val cutout = getDisplayCutoutInsetsCompat(insets) return EdgeInsets( - top = insets.systemWindowInsetTop.toFloat(), - right = insets.systemWindowInsetRight.toFloat(), + top = if (isStatusBarHidden) cutout.top else insets.systemWindowInsetTop.toFloat(), + right = if (isNavigationBarHidden) cutout.right else insets.systemWindowInsetRight.toFloat(), // System insets are more reliable to account for notches but the // system inset for bottom includes the soft keyboard which we don't // want to be consistent with iOS. Using the min value makes sure we // never get the keyboard offset while still working with devices that // hide the navigation bar. - bottom = min(insets.systemWindowInsetBottom, insets.stableInsetBottom).toFloat(), - left = insets.systemWindowInsetLeft.toFloat(), + bottom = + if (isNavigationBarHidden) cutout.bottom + else min(insets.systemWindowInsetBottom, insets.stableInsetBottom).toFloat(), + left = if (isNavigationBarHidden) cutout.left else insets.systemWindowInsetLeft.toFloat(), ) } diff --git a/android/src/test/java/com/th3rdwave/safeareacontext/SafeAreaUtilsTest.kt b/android/src/test/java/com/th3rdwave/safeareacontext/SafeAreaUtilsTest.kt new file mode 100644 index 00000000..e91792ae --- /dev/null +++ b/android/src/test/java/com/th3rdwave/safeareacontext/SafeAreaUtilsTest.kt @@ -0,0 +1,118 @@ +package com.th3rdwave.safeareacontext + +import android.graphics.Insets +import android.view.DisplayCutout +import android.view.View +import android.view.WindowInsets +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class SafeAreaUtilsTest { + @Test + fun aStatusBarHiddenThroughTheSystemUiVisibilityLeavesNoTopInset() { + val view = viewFillingScreen(systemUiVisibility = EDGE_TO_EDGE or HIDDEN_STATUS_BAR) + + assertEquals( + EdgeInsets(top = 0f, right = 0f, bottom = NAVIGATION_BAR, left = 0f), + getSafeAreaInsets(view), + ) + } + + @Test + fun aHiddenStatusBarLeavesTheDisplayCutoutsTopInset() { + val view = + viewFillingScreen( + systemUiVisibility = EDGE_TO_EDGE or HIDDEN_STATUS_BAR, + cutoutTop = CUTOUT, + ) + + assertEquals( + EdgeInsets(top = CUTOUT, right = 0f, bottom = NAVIGATION_BAR, left = 0f), + getSafeAreaInsets(view), + ) + } + + @Test + fun aVisibleStatusBarKeepsItsInset() { + val view = viewFillingScreen(systemUiVisibility = EDGE_TO_EDGE) + + assertEquals( + EdgeInsets(top = STATUS_BAR, right = 0f, bottom = NAVIGATION_BAR, left = 0f), + getSafeAreaInsets(view), + ) + } + + @Test + fun aNavigationBarHiddenThroughTheSystemUiVisibilityLeavesNoBottomInset() { + val view = viewFillingScreen(systemUiVisibility = EDGE_TO_EDGE or HIDDEN_NAVIGATION_BAR) + + assertEquals( + EdgeInsets(top = STATUS_BAR, right = 0f, bottom = 0f, left = 0f), + getSafeAreaInsets(view), + ) + } + + private fun viewFillingScreen(systemUiVisibility: Int, cutoutTop: Float = 0f): View { + val cutout = + if (cutoutTop > 0f) { + DisplayCutout( + Insets.of(0, cutoutTop.toInt(), 0, 0), + null, + android.graphics.Rect(0, 0, SCREEN_WIDTH, cutoutTop.toInt()), + null, + null, + ) + } else { + null + } + val systemBars = Insets.of(0, STATUS_BAR.toInt(), 0, NAVIGATION_BAR.toInt()) + val insets = + WindowInsets.Builder() + .setSystemWindowInsets(systemBars) + .setStableInsets(systemBars) + .setDisplayCutout(cutout) + .build() + val rootView = + mock { + on { rootWindowInsets } doReturn insets + on { windowSystemUiVisibility } doReturn systemUiVisibility + on { width } doReturn SCREEN_WIDTH + on { height } doReturn SCREEN_HEIGHT + } + return mock { + on { this.rootView } doReturn rootView + on { width } doReturn SCREEN_WIDTH + on { height } doReturn SCREEN_HEIGHT + on { getGlobalVisibleRect(any()) } doAnswer + { + it.getArgument(0).set(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) + true + } + } + } + + private companion object { + @Suppress("DEPRECATION") + const val EDGE_TO_EDGE: Int = + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + @Suppress("DEPRECATION") const val HIDDEN_STATUS_BAR: Int = View.SYSTEM_UI_FLAG_FULLSCREEN + @Suppress("DEPRECATION") + const val HIDDEN_NAVIGATION_BAR: Int = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + const val SCREEN_WIDTH: Int = 1080 + const val SCREEN_HEIGHT: Int = 2340 + const val STATUS_BAR: Float = 96f + const val NAVIGATION_BAR: Float = 126f + const val CUTOUT: Float = 80f + } +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 63bc9541..827321f4 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -27,7 +27,7 @@ android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Jetifier randomly fails on these libraries -android.jetifier.ignorelist=hermes-android,react-android +android.jetifier.ignorelist=hermes-android,react-android,bcprov-jdk18on # Use this property to specify which architecture you want to build. # You can also override it from the CLI using diff --git a/package.json b/package.json index 71e10726..91a357be 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "Package.swift", "react-native.config.js", "!**/__tests__", + "!android/src/test", "!example" ], "author": "Janic Duplessis ", diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index d9e6f99b..0d3bd6bd 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -64,7 +64,8 @@ for (const entry of entries) { ) || /^package\/(?:src|lib\/(?:commonjs|module|typescript)\/src)\/__tests__(?:\/|$)/.test( entry, - ) + ) || + /^package\/android\/src\/test(?:\/|$)/.test(entry) ) { throw new Error(`Unexpected repository-only file: ${entry}`); } From 475be68661521da519273fe924406df4f33d484a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 20:52:44 +0300 Subject: [PATCH 2/2] fix: keep the insets of bars the system shows in multi-window mode In split-screen and freeform mode Android 7-10 keeps the system bars visible whatever the app requested, and Android 7-9 lays the app out under them, so a status bar hidden through SYSTEM_UI_FLAG_FULLSCREEN still covers the top of the window there. While a flag hides a bar, ask the activity whether it is in multi-window mode, and keep the bars' insets when it is. The question is a binder call, so an app hiding no bar never asks it. --- .../safeareacontext/SafeAreaContextModule.kt | 5 +- .../safeareacontext/SafeAreaUtils.kt | 44 +++++++++++--- .../safeareacontext/SafeAreaUtilsTest.kt | 57 ++++++++++++++++++- 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt index 40526b6b..38359c0e 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaContextModule.kt @@ -17,9 +17,10 @@ class SafeAreaContextModule(reactContext: ReactApplicationContext?) : } private fun getInitialWindowMetrics(): Map? { - val decorView = reactApplicationContext.currentActivity?.window?.decorView as ViewGroup? + val activity = reactApplicationContext.currentActivity + val decorView = activity?.window?.decorView as ViewGroup? val contentView = decorView?.findViewById(android.R.id.content) ?: return null - val insets = getSafeAreaInsets(decorView) + val insets = getSafeAreaInsets(decorView, activity) val frame = getFrame(decorView, contentView) return if (insets == null || frame == null) { null diff --git a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaUtils.kt b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaUtils.kt index d7420882..f1ebf915 100644 --- a/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaUtils.kt +++ b/android/src/main/java/com/th3rdwave/safeareacontext/SafeAreaUtils.kt @@ -1,5 +1,8 @@ package com.th3rdwave.safeareacontext +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.os.Build import android.view.View import android.view.ViewGroup @@ -45,15 +48,35 @@ private fun getDisplayCutoutInsetsCompat(insets: WindowInsets): EdgeInsets { } } +private fun isInMultiWindowMode(context: Context?): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return false + } + var current = context + while (current is ContextWrapper) { + if (current is Activity) { + return current.isInMultiWindowMode + } + current = current.baseContext + } + return false +} + @RequiresApi(Build.VERSION_CODES.M) @Suppress("DEPRECATION") -private fun getRootWindowInsetsCompatM(rootView: View): EdgeInsets? { +private fun getRootWindowInsetsCompatM(rootView: View, activityContext: Context?): EdgeInsets? { val insets = rootView.rootWindowInsets ?: return null // Under SYSTEM_UI_FLAG_LAYOUT_STABLE a hidden bar keeps its system window inset, so a bar hidden // through the system UI visibility leaves only the display cutout, as getInsets does from API 30. val systemUiVisibility = rootView.windowSystemUiVisibility - val isStatusBarHidden = (systemUiVisibility and View.SYSTEM_UI_FLAG_FULLSCREEN) != 0 - val isNavigationBarHidden = (systemUiVisibility and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) != 0 + val hidesStatusBar = (systemUiVisibility and View.SYSTEM_UI_FLAG_FULLSCREEN) != 0 + val hidesNavigationBar = (systemUiVisibility and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) != 0 + // In multi-window mode the system shows the bars whatever the app requested. Asking is a binder + // call, so only an app hiding a bar asks. + val showsBarsAnyway = + (hidesStatusBar || hidesNavigationBar) && isInMultiWindowMode(activityContext) + val isStatusBarHidden = hidesStatusBar && !showsBarsAnyway + val isNavigationBarHidden = hidesNavigationBar && !showsBarsAnyway val cutout = getDisplayCutoutInsetsCompat(insets) return EdgeInsets( top = if (isStatusBarHidden) cutout.top else insets.systemWindowInsetTop.toFloat(), @@ -81,21 +104,28 @@ private fun getRootWindowInsetsCompatBase(rootView: View): EdgeInsets? { ) } -private fun getRootWindowInsetsCompat(rootView: View): EdgeInsets? { +private fun getRootWindowInsetsCompat(rootView: View, activityContext: Context?): EdgeInsets? { return when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> getRootWindowInsetsCompatR(rootView) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> getRootWindowInsetsCompatM(rootView) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> + getRootWindowInsetsCompatM(rootView, activityContext) else -> getRootWindowInsetsCompatBase(rootView) } } -fun getSafeAreaInsets(view: View): EdgeInsets? { +fun getSafeAreaInsets(view: View): EdgeInsets? = getSafeAreaInsets(view, view.context) + +/** + * [activityContext] leads to the activity whose window holds [view], which a decor view's own + * context does not. + */ +fun getSafeAreaInsets(view: View, activityContext: Context?): EdgeInsets? { // The view has not been layout yet. if (view.height == 0) { return null } val rootView = view.rootView - val windowInsets = getRootWindowInsetsCompat(rootView) ?: return null + val windowInsets = getRootWindowInsetsCompat(rootView, activityContext) ?: return null // Calculate the part of the view that overlaps with window insets. val windowWidth = rootView.width.toFloat() diff --git a/android/src/test/java/com/th3rdwave/safeareacontext/SafeAreaUtilsTest.kt b/android/src/test/java/com/th3rdwave/safeareacontext/SafeAreaUtilsTest.kt index e91792ae..0bb11b03 100644 --- a/android/src/test/java/com/th3rdwave/safeareacontext/SafeAreaUtilsTest.kt +++ b/android/src/test/java/com/th3rdwave/safeareacontext/SafeAreaUtilsTest.kt @@ -1,5 +1,9 @@ package com.th3rdwave.safeareacontext +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper import android.graphics.Insets import android.view.DisplayCutout import android.view.View @@ -11,6 +15,8 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -61,7 +67,55 @@ class SafeAreaUtilsTest { ) } - private fun viewFillingScreen(systemUiVisibility: Int, cutoutTop: Float = 0f): View { + @Test + fun barsTheSystemShowsInMultiWindowModeKeepTheirInsets() { + val view = + viewFillingScreen( + systemUiVisibility = EDGE_TO_EDGE or HIDDEN_STATUS_BAR or HIDDEN_NAVIGATION_BAR, + viewContext = activityContext(isInMultiWindowMode = true), + ) + + assertEquals( + EdgeInsets(top = STATUS_BAR, right = 0f, bottom = NAVIGATION_BAR, left = 0f), + getSafeAreaInsets(view), + ) + } + + @Test + fun aDecorViewTakesMultiWindowModeFromTheActivityItIsGiven() { + val decorView = + viewFillingScreen( + systemUiVisibility = EDGE_TO_EDGE or HIDDEN_STATUS_BAR or HIDDEN_NAVIGATION_BAR, + viewContext = ContextWrapper(mock()), + ) + + assertEquals( + EdgeInsets(top = STATUS_BAR, right = 0f, bottom = NAVIGATION_BAR, left = 0f), + getSafeAreaInsets(decorView, activityContext(isInMultiWindowMode = true)), + ) + } + + @Test + fun anAppHidingNoBarNeverAsksWhetherItIsInMultiWindowMode() { + val activity = mock() + val view = + viewFillingScreen(systemUiVisibility = EDGE_TO_EDGE, viewContext = ContextWrapper(activity)) + + getSafeAreaInsets(view) + + verify(activity, never()).isInMultiWindowMode + } + + private fun activityContext(isInMultiWindowMode: Boolean): Context = + ContextWrapper( + mock { on { this.isInMultiWindowMode } doReturn isInMultiWindowMode } + ) + + private fun viewFillingScreen( + systemUiVisibility: Int, + cutoutTop: Float = 0f, + viewContext: Context = activityContext(isInMultiWindowMode = false), + ): View { val cutout = if (cutoutTop > 0f) { DisplayCutout( @@ -89,6 +143,7 @@ class SafeAreaUtilsTest { on { height } doReturn SCREEN_HEIGHT } return mock { + on { context } doReturn viewContext on { this.rootView } doReturn rootView on { width } doReturn SCREEN_WIDTH on { height } doReturn SCREEN_HEIGHT