Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ class SafeAreaContextModule(reactContext: ReactApplicationContext?) :
}

private fun getInitialWindowMetrics(): Map<String, Any>? {
val decorView = reactApplicationContext.currentActivity?.window?.decorView as ViewGroup?
val activity = reactApplicationContext.currentActivity
val decorView = activity?.window?.decorView as ViewGroup?
val contentView = decorView?.findViewById<View>(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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,20 +29,67 @@ 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)
}
}

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 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 = 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(),
)
}

Expand All @@ -54,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()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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
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.mockito.kotlin.never
import org.mockito.kotlin.verify
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),
)
}

@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<Application>()),
)

assertEquals(
EdgeInsets(top = STATUS_BAR, right = 0f, bottom = NAVIGATION_BAR, left = 0f),
getSafeAreaInsets(decorView, activityContext(isInMultiWindowMode = true)),
)
}

@Test
fun anAppHidingNoBarNeverAsksWhetherItIsInMultiWindowMode() {
val activity = mock<Activity>()
val view =
viewFillingScreen(systemUiVisibility = EDGE_TO_EDGE, viewContext = ContextWrapper(activity))

getSafeAreaInsets(view)

verify(activity, never()).isInMultiWindowMode
}

private fun activityContext(isInMultiWindowMode: Boolean): Context =
ContextWrapper(
mock<Activity> { 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(
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<View> {
on { rootWindowInsets } doReturn insets
on { windowSystemUiVisibility } doReturn systemUiVisibility
on { width } doReturn SCREEN_WIDTH
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
on { getGlobalVisibleRect(any()) } doAnswer
{
it.getArgument<android.graphics.Rect>(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
}
}
2 changes: 1 addition & 1 deletion example/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"Package.swift",
"react-native.config.js",
"!**/__tests__",
"!android/src/test",
"!example"
],
"author": "Janic Duplessis <janicduplessis@gmail.com>",
Expand Down
3 changes: 2 additions & 1 deletion scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down