Fix insets handling when using OnExitAnimationListener

On API 31, the SplashScreenView sets window.setDecorFitsSystemWindows(false) when an
OnExitAnimationListener is used. This also affects the application content that will
be pushed up under the status bar even though it didn't requested it.
And once the SplashScreenView is removed, the the whole layout jumps back below the
status bar.
Fortunately, this happens only after the view is attached, so we have time to record
the value of window.setDecorFitsSystemWindows() before the splash screen
modifies it and reapply the correct value to the window.

Test: SplashscreenParametrizedTest#decorFitSystemStableContentView#decorFitSystemStableContentView
Bug: 197870763
Change-Id: I59850c8e66c38ca9a7188863735489547f7efe9b
diff --git a/core/core-splashscreen/samples/src/main/res/values/styles.xml b/core/core-splashscreen/samples/src/main/res/values/styles.xml
index 06982bf..549060b 100644
--- a/core/core-splashscreen/samples/src/main/res/values/styles.xml
+++ b/core/core-splashscreen/samples/src/main/res/values/styles.xml
@@ -16,11 +16,13 @@
 
 <resources>
 
-    <style name="Theme.App" parent="Theme.AppCompat.Light.NoActionBar">
-        <item name="android:windowBackground">@color/windowBackground</item>
-        <item name="android:statusBarColor">@android:color/transparent</item>
-        <item name="android:navigationBarColor">@android:color/transparent</item>
+    <style name="Theme.App" parent="Theme.AppCompat.DayNight.NoActionBar">
+        <item name="android:statusBarColor">#AAFE0000</item>
         <item name="android:windowDrawsSystemBarBackgrounds">true</item>
+        <item name="android:navigationBarColor">@android:color/transparent</item>
+        <item name="android:enforceNavigationBarContrast">true</item>
+        <item name="android:windowLightNavigationBar">true</item>
+        <item name="android:windowLightStatusBar">true</item>
     </style>
 
     <style name="Theme.App.Starting" parent="Theme.SplashScreen.IconBackground">
diff --git a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashScreenTestController.kt b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashScreenTestController.kt
index dd4d89a..25a66c6 100644
--- a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashScreenTestController.kt
+++ b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashScreenTestController.kt
@@ -19,24 +19,43 @@
 import android.app.Activity
 import android.graphics.Bitmap
 import android.graphics.drawable.Drawable
+import android.os.Bundle
 import android.util.TypedValue
 import android.view.View
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.splashscreen.SplashScreenViewProvider
 import androidx.test.runner.screenshot.Screenshot
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.atomic.AtomicBoolean
 import androidx.core.splashscreen.R as SR
 
+/**
+ * If true, sets an [androidx.core.splashscreen.SplashScreen.OnExitAnimationListener] on the
+ * Activity
+ */
 internal const val EXTRA_ANIMATION_LISTENER = "AnimationListener"
+
+/**
+ * If true, sets a [androidx.core.splashscreen.SplashScreen.KeepOnScreenCondition] waiting until
+ * [SplashScreenTestController.waitBarrier] is set to `false`.
+ * Activity
+ */
 internal const val EXTRA_SPLASHSCREEN_WAIT = "splashscreen_wait"
+
+/**
+ * If set to true, takes a screenshot of the splash screen and saves it in
+ * [SplashScreenTestController.splashScreenScreenshot] and a second screenshot of
+ * [androidx.core.splashscreen.SplashScreenViewProvider.view]
+ * and saves it in [SplashScreenTestController.splashScreenViewScreenShot]
+ */
 internal const val EXTRA_SPLASHSCREEN_VIEW_SCREENSHOT = "SplashScreenViewScreenShot"
 
 public interface SplashScreenTestControllerHolder {
     public var controller: SplashScreenTestController
 }
 
-public class SplashScreenTestController(private val activity: Activity) {
+public class SplashScreenTestController(internal val activity: Activity) {
 
     public var splashScreenViewScreenShot: Bitmap? = null
     public var splashScreenScreenshot: Bitmap? = null
@@ -58,14 +77,27 @@
     public val waitBarrier: AtomicBoolean = AtomicBoolean(true)
     public var hasDrawn: Boolean = false
 
+    private var onExitAnimationListener: (SplashScreenViewProvider) -> Boolean = { false }
+
+    /**
+     * Call [onExitAnimation] when the
+     * [androidx.core.splashscreen.SplashScreen.OnExitAnimationListener] is called. This requires
+     * [EXTRA_ANIMATION_LISTENER] to be set to true.
+     * If [onExitAnimation] returns true, [SplashScreenViewProvider] won't be removed and the
+     * OnExitAnimationListener returns immediately.
+     */
+    fun doOnExitAnimation(onExitAnimation: (SplashScreenViewProvider) -> Boolean) {
+        onExitAnimationListener = onExitAnimation
+    }
+
     public fun onCreate() {
         val intent = activity.intent
         val theme = activity.theme
+        val extras = intent.extras ?: Bundle.EMPTY
 
-        val useListener = intent.extras?.getBoolean(EXTRA_ANIMATION_LISTENER) ?: false
-        val takeScreenShot =
-            intent.extras?.getBoolean(EXTRA_SPLASHSCREEN_VIEW_SCREENSHOT) ?: false
-        val waitForSplashscreen = intent.extras?.getBoolean(EXTRA_SPLASHSCREEN_WAIT) ?: false
+        val useListener = extras.getBoolean(EXTRA_ANIMATION_LISTENER)
+        val takeScreenShot = extras.getBoolean(EXTRA_SPLASHSCREEN_VIEW_SCREENSHOT)
+        val waitForSplashscreen = extras.getBoolean(EXTRA_SPLASHSCREEN_WAIT)
 
         val tv = TypedValue()
         theme.resolveAttribute(SR.attr.windowSplashScreenAnimatedIcon, tv, true)
@@ -84,6 +116,7 @@
         duration = tv.data
 
         val splashScreen = activity.installSplashScreen()
+
         activity.setContentView(R.layout.main_activity)
 
         if (waitForSplashscreen) {
@@ -102,6 +135,9 @@
                 splashScreenView = splashScreenViewProvider.view
                 splashScreenIconView = splashScreenViewProvider.iconView
                 splashScreenIconViewBackground = splashScreenViewProvider.iconView.background
+                if (onExitAnimationListener(splashScreenViewProvider)) {
+                    return@setOnExitAnimationListener
+                }
                 if (takeScreenShot) {
                     splashScreenViewProvider.view.postDelayed(
                         {
diff --git a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
index acecf31..3418529 100644
--- a/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
+++ b/core/core-splashscreen/src/androidTest/java/androidx/core/splashscreen/test/SplashscreenParametrizedTest.kt
@@ -19,6 +19,9 @@
 import android.content.Intent
 import android.graphics.Bitmap
 import android.os.Bundle
+import android.view.View
+import android.view.ViewTreeObserver
+import androidx.core.splashscreen.SplashScreenViewProvider
 import androidx.test.filters.LargeTest
 import androidx.test.filters.SdkSuppress
 import androidx.test.platform.app.InstrumentationRegistry
@@ -36,6 +39,7 @@
 import java.io.File
 import java.io.FileOutputStream
 import java.io.IOException
+import java.util.concurrent.CountDownLatch
 import java.util.concurrent.TimeUnit
 import kotlin.reflect.KClass
 
@@ -155,6 +159,48 @@
         compareBitmaps(activity.splashScreenScreenshot!!, activity.splashScreenViewScreenShot!!)
     }
 
+    /**
+     * The splash screen is drawn full screen. On Android 12, this is achieved using
+     * [Window.setDecorFitsSystemWindow(false)].
+     */
+    @Test
+    public fun decorFitSystemStableContentView() {
+        val activityController = startActivityWithSplashScreen {
+            // Clear out any previous instances
+            it.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+            it.putExtra(EXTRA_ANIMATION_LISTENER, true)
+            it.putExtra(EXTRA_SPLASHSCREEN_WAIT, true)
+        }
+
+        // We wait for 2 draw passes and check that out content view's height is stable
+        val drawLatch = CountDownLatch(2)
+        val container = activityController.activity.findViewById<View>(R.id.container)
+        val contentViewHeights = mutableListOf<Int>()
+        var splashScreenViewProvider: SplashScreenViewProvider? = null
+
+        val onDrawListener = ViewTreeObserver.OnDrawListener {
+            contentViewHeights.add(container.height)
+            drawLatch.countDown()
+            if (drawLatch.count == 1L) {
+                splashScreenViewProvider!!.remove()
+            }
+        }
+
+        activityController.doOnExitAnimation {
+            splashScreenViewProvider = it
+            container.viewTreeObserver.addOnDrawListener(onDrawListener)
+            activityController.splashScreenView!!.alpha = 0f
+            true
+        }
+
+        activityController.waitBarrier.set(false)
+        assertTrue("Missing ${drawLatch.count} draw passes.", drawLatch.await(2, TimeUnit.SECONDS))
+        assertTrue(
+            "Content view height must be stable but was ${
+                contentViewHeights.joinToString(",")
+            }", contentViewHeights.all { it == contentViewHeights.first() })
+    }
+
     private fun compareBitmaps(
         beforeScreenshot: Bitmap,
         afterScreenshot: Bitmap
diff --git a/core/core-splashscreen/src/androidTest/res/values/styles.xml b/core/core-splashscreen/src/androidTest/res/values/styles.xml
index d028ccc..4a9e72b 100644
--- a/core/core-splashscreen/src/androidTest/res/values/styles.xml
+++ b/core/core-splashscreen/src/androidTest/res/values/styles.xml
@@ -21,7 +21,6 @@
         <item name="android:fitsSystemWindows">false</item>
         <item name="android:statusBarColor">@android:color/transparent</item>
         <item name="android:navigationBarColor">@android:color/transparent</item>
-        <item name="android:windowLightStatusBar">false</item>
     </style>
 
     <style name="Theme.Test.Starting" parent="Theme.SplashScreen">
diff --git a/core/core-splashscreen/src/main/java/androidx/core/splashscreen/SplashScreen.kt b/core/core-splashscreen/src/main/java/androidx/core/splashscreen/SplashScreen.kt
index d99ffd6..1fc3ebe 100644
--- a/core/core-splashscreen/src/main/java/androidx/core/splashscreen/SplashScreen.kt
+++ b/core/core-splashscreen/src/main/java/androidx/core/splashscreen/SplashScreen.kt
@@ -19,14 +19,18 @@
 import android.annotation.SuppressLint
 import android.app.Activity
 import android.content.res.Resources
+import android.graphics.Rect
 import android.graphics.drawable.Drawable
 import android.os.Build
 import android.os.Build.VERSION.SDK_INT
 import android.util.TypedValue
 import android.view.View
 import android.view.View.OnLayoutChangeListener
+import android.view.ViewGroup
 import android.view.ViewTreeObserver.OnPreDrawListener
+import android.view.WindowInsets
 import android.widget.ImageView
+import android.window.SplashScreenView
 import androidx.annotation.MainThread
 import androidx.annotation.RequiresApi
 import androidx.core.splashscreen.SplashScreen.KeepOnScreenCondition
@@ -106,7 +110,7 @@
  *  - Without icon background  (`Theme.SplashScreen`)
  *     + Image size: 288x288 dp
  *     + Inner circle diameter: 192 dp
-  *
+ *
  *  _Example:_ if the full size of the image is 300dp*300dp, the icon needs to fit within a
  *  circle with a diameter of 200dp. Everything outside the circle will be invisible (masked).
  *
@@ -374,9 +378,52 @@
     @RequiresApi(Build.VERSION_CODES.S)
     private class Impl31(activity: Activity) : Impl(activity) {
         var preDrawListener: OnPreDrawListener? = null
+        var mDecorFitWindowInsets = true
+
+        val hierarchyListener = object : ViewGroup.OnHierarchyChangeListener {
+            override fun onChildViewAdded(parent: View?, child: View?) {
+
+                if (child is SplashScreenView) {
+                    /*
+                     * On API 31, the SplashScreenView sets window.setDecorFitsSystemWindows(false)
+                     * when an OnExitAnimationListener is used. This also affects the application
+                     * content that will be pushed up under the status bar even though it didn't
+                     * requested it. And once the SplashScreenView is removed, the whole layout
+                     * jumps back below the status bar. Fortunately, this happens only after the
+                     * view is attached, so we have time to record the value of
+                     * window.setDecorFitsSystemWindows() before the splash screen modifies it and
+                     * reapply the correct value to the window.
+                     */
+                    mDecorFitWindowInsets = computeDecorFitsWindow(child)
+                    (activity.window.decorView as ViewGroup).setOnHierarchyChangeListener(null)
+                }
+            }
+
+            override fun onChildViewRemoved(parent: View?, child: View?) {
+                // no-op
+            }
+        }
+
+        fun computeDecorFitsWindow(child: SplashScreenView): Boolean {
+            val inWindowInsets = WindowInsets.Builder().build()
+            val outLocalInsets = Rect(
+                Int.MIN_VALUE, Int.MIN_VALUE, Int.MAX_VALUE,
+                Int.MAX_VALUE
+            )
+
+            // If setDecorFitWindowInsets is set to false, computeSystemWindowInsets
+            // will return the same instance of WindowInsets passed in its parameter and
+            // will set outLocalInsets to empty, so we check that both conditions are
+            // filled to extrapolate the value of setDecorFitWindowInsets
+            return !(inWindowInsets === child.rootView.computeSystemWindowInsets
+                (inWindowInsets, outLocalInsets) && outLocalInsets.isEmpty)
+        }
 
         override fun install() {
             setPostSplashScreenTheme(activity.theme, TypedValue())
+            (activity.window.decorView as ViewGroup).setOnHierarchyChangeListener(
+                hierarchyListener
+            )
         }
 
         override fun setKeepOnScreenCondition(keepOnScreenCondition: KeepOnScreenCondition) {
@@ -402,10 +449,19 @@
         override fun setOnExitAnimationListener(
             exitAnimationListener: OnExitAnimationListener
         ) {
-            activity.splashScreen.setOnExitAnimationListener {
-                val splashScreenViewProvider = SplashScreenViewProvider(it, activity)
+            activity.splashScreen.setOnExitAnimationListener { splashScreenView ->
+                applyAppSystemUiTheme()
+                val splashScreenViewProvider = SplashScreenViewProvider(splashScreenView, activity)
                 exitAnimationListener.onSplashScreenExit(splashScreenViewProvider)
             }
         }
+
+        private fun applyAppSystemUiTheme() {
+            val window = activity.window
+
+            // Fix setDecorFitsSystemWindows being overridden by the SplashScreenView
+            (activity.window.decorView as ViewGroup).setOnHierarchyChangeListener(null)
+            window.setDecorFitsSystemWindows(mDecorFitWindowInsets)
+        }
     }
 }