Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 62a8d73e authored by Evan Laird's avatar Evan Laird
Browse files

[Sb chip] Fix chip layout when fullscreen or rotating

When launching an activity from the ongoing call chip, we end up setting
the StatusBar window to fullscreen for a few frames. This would cause
SystemEventChipAnimationController to position the chip halfway down the
display due to positioning the container view using CENTER_VERTICAL.

While testing, I also discovered that any rotation that occurred during
the animation would not be properly reflected. Opening the camera app
while in landscape would sometimes not show the chip at all due to
triggering a rotation AND a privacy event at the same time. The fix for
this is to use the StatusBarContentInsetsChangedListener to re-solve for
the animation rect bounds.

Test: manual
Test: SystemEventChipAnimationControllerTest
Fixes: 289378932
Change-Id: I9f9909842999dd3c2cbd11c392223b7f213eff3a
parent b1aa9460
Loading
Loading
Loading
Loading
+61 −23
Original line number Diff line number Diff line
@@ -30,9 +30,11 @@ import androidx.core.animation.Animator
import androidx.core.animation.AnimatorListenerAdapter
import androidx.core.animation.AnimatorSet
import androidx.core.animation.ValueAnimator
import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.R
import com.android.systemui.flags.FeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.statusbar.phone.StatusBarContentInsetsChangedListener
import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider
import com.android.systemui.statusbar.window.StatusBarWindowController
import com.android.systemui.util.animation.AnimationUtil.Companion.frames
@@ -46,7 +48,7 @@ class SystemEventChipAnimationController @Inject constructor(
    private val context: Context,
    private val statusBarWindowController: StatusBarWindowController,
    private val contentInsetsProvider: StatusBarContentInsetsProvider,
    private val featureFlags: FeatureFlags
    private val featureFlags: FeatureFlags,
) : SystemStatusAnimationCallback {

    private lateinit var animationWindowView: FrameLayout
@@ -56,7 +58,8 @@ class SystemEventChipAnimationController @Inject constructor(

    // Left for LTR, Right for RTL
    private var animationDirection = LEFT
    private var chipBounds = Rect()

    @VisibleForTesting var chipBounds = Rect()
    private val chipWidth get() = chipBounds.width()
    private val chipRight get() = chipBounds.right
    private val chipLeft get() = chipBounds.left
@@ -69,7 +72,7 @@ class SystemEventChipAnimationController @Inject constructor(
    private var animRect = Rect()

    // TODO: move to dagger
    private var initialized = false
    @VisibleForTesting var initialized = false

    /**
     * Give the chip controller a chance to inflate and configure the chip view before we start
@@ -98,23 +101,7 @@ class SystemEventChipAnimationController @Inject constructor(
                    View.MeasureSpec.makeMeasureSpec(
                            (animationWindowView.parent as View).height, AT_MOST))

            // decide which direction we're animating from, and then set some screen coordinates
            val contentRect = contentInsetsProvider.getStatusBarContentAreaForCurrentRotation()
            val chipTop = ((animationWindowView.parent as View).height - it.view.measuredHeight) / 2
            val chipBottom = chipTop + it.view.measuredHeight
            val chipRight: Int
            val chipLeft: Int
            when (animationDirection) {
                LEFT -> {
                    chipRight = contentRect.right
                    chipLeft = contentRect.right - it.chipWidth
                }
                else /* RIGHT */ -> {
                    chipLeft = contentRect.left
                    chipRight = contentRect.left + it.chipWidth
                }
            }
            chipBounds = Rect(chipLeft, chipTop, chipRight, chipBottom)
            updateChipBounds(it, contentInsetsProvider.getStatusBarContentAreaForCurrentRotation())
        }
    }

@@ -253,16 +240,67 @@ class SystemEventChipAnimationController @Inject constructor(
        return animSet
    }

    private fun init() {
    fun init() {
        initialized = true
        themedContext = ContextThemeWrapper(context, R.style.Theme_SystemUI_QuickSettings)
        animationWindowView = LayoutInflater.from(themedContext)
                .inflate(R.layout.system_event_animation_window, null) as FrameLayout
        val lp = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
        lp.gravity = Gravity.END or Gravity.CENTER_VERTICAL
        // Matches status_bar.xml
        val height = themedContext.resources.getDimensionPixelSize(R.dimen.status_bar_height)
        val lp = FrameLayout.LayoutParams(MATCH_PARENT, height)
        lp.gravity = Gravity.END or Gravity.TOP
        statusBarWindowController.addViewToWindow(animationWindowView, lp)
        animationWindowView.clipToPadding = false
        animationWindowView.clipChildren = false

        // Use contentInsetsProvider rather than configuration controller, since we only care
        // about status bar dimens
        contentInsetsProvider.addCallback(object : StatusBarContentInsetsChangedListener {
            override fun onStatusBarContentInsetsChanged() {
                val newContentArea = contentInsetsProvider
                    .getStatusBarContentAreaForCurrentRotation()
                updateDimens(newContentArea)

                // If we are currently animating, we have to re-solve for the chip bounds. If we're
                // not animating then [prepareChipAnimation] will take care of it for us
                currentAnimatedView?.let {
                    updateChipBounds(it, newContentArea)
                }
            }
        })
    }

    private fun updateDimens(contentArea: Rect) {
        val lp = animationWindowView.layoutParams as FrameLayout.LayoutParams
        lp.height = contentArea.height()

        animationWindowView.layoutParams = lp
    }

    /**
     * Use the current status bar content area and the current chip's measured size to update
     * the animation rect and chipBounds. This method can be called at any time and will update
     * the current animation values properly during e.g. a rotation.
     */
    private fun updateChipBounds(chip: BackgroundAnimatableView, contentArea: Rect) {
        // decide which direction we're animating from, and then set some screen coordinates
        val chipTop = (contentArea.bottom - chip.view.measuredHeight) / 2
        val chipBottom = chipTop + chip.view.measuredHeight
        val chipRight: Int
        val chipLeft: Int

        when (animationDirection) {
            LEFT -> {
                chipRight = contentArea.right
                chipLeft = contentArea.right - chip.chipWidth
            }
            else /* RIGHT */ -> {
                chipLeft = contentArea.left
                chipRight = contentArea.left + chip.chipWidth
            }
        }
        chipBounds = Rect(chipLeft, chipTop, chipRight, chipBottom)
        animRect.set(chipBounds)
    }

    private fun layoutParamsDefault(marginEnd: Int): FrameLayout.LayoutParams =
+189 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.statusbar.events

import android.content.Context
import android.graphics.Rect
import android.util.Pair
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.flags.FakeFeatureFlags
import com.android.systemui.statusbar.phone.StatusBarContentInsetsChangedListener
import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider
import com.android.systemui.statusbar.window.StatusBarWindowController
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.capture
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations

@SmallTest
class SystemEventChipAnimationControllerTest : SysuiTestCase() {
    private lateinit var controller: SystemEventChipAnimationController

    @Mock private lateinit var sbWindowController: StatusBarWindowController
    @Mock private lateinit var insetsProvider: StatusBarContentInsetsProvider

    private var testView = TestView(mContext)
    private var viewCreator: ViewCreator = { testView }

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)

        // StatusBarWindowController is mocked. The addViewToWindow function needs to be mocked to
        // ensure that the chip view is added to a parent view
        whenever(sbWindowController.addViewToWindow(any(), any())).then {
            val statusbarFake = FrameLayout(mContext)
            statusbarFake.layout(
                portraitArea.left,
                portraitArea.top,
                portraitArea.right,
                portraitArea.bottom,
            )
            statusbarFake.addView(
                it.arguments[0] as View,
                it.arguments[1] as FrameLayout.LayoutParams
            )
        }

        whenever(insetsProvider.getStatusBarContentInsetsForCurrentRotation())
            .thenReturn(Pair(insets, insets))
        whenever(insetsProvider.getStatusBarContentAreaForCurrentRotation())
            .thenReturn(portraitArea)

        controller =
            SystemEventChipAnimationController(
                context = mContext,
                statusBarWindowController = sbWindowController,
                contentInsetsProvider = insetsProvider,
                featureFlags = FakeFeatureFlags(),
            )
    }

    @Test
    fun prepareChipAnimation_lazyInitializes() {
        // Until Dagger can do our initialization, make sure that the first chip animation calls
        // init()
        assertFalse(controller.initialized)
        controller.prepareChipAnimation(viewCreator)
        assertTrue(controller.initialized)
    }

    @Test
    fun prepareChipAnimation_positionsChip() {
        controller.prepareChipAnimation(viewCreator)
        val chipRect = controller.chipBounds

        // SB area = 10, 0, 990, 100
        // chip size = 0, 0, 100, 50
        assertThat(chipRect).isEqualTo(Rect(890, 25, 990, 75))
    }

    @Test
    fun prepareChipAnimation_rotation_repositionsChip() {
        controller.prepareChipAnimation(viewCreator)

        // Chip has been prepared, and is located at (890, 25, 990, 75)
        // Rotation should put it into its landscape location:
        // SB area = 10, 0, 1990, 80
        // chip size = 0, 0, 100, 50

        whenever(insetsProvider.getStatusBarContentAreaForCurrentRotation())
            .thenReturn(landscapeArea)
        getInsetsListener().onStatusBarContentInsetsChanged()

        val chipRect = controller.chipBounds
        assertThat(chipRect).isEqualTo(Rect(1890, 15, 1990, 65))
    }

    /** regression test for (b/289378932) */
    @Test
    fun fullScreenStatusBar_positionsChipAtTop_withTopGravity() {
        // In the case of a fullscreen status bar window, the content insets area is still correct
        // (because it uses the dimens), but the window can be full screen. This seems to happen
        // when launching an app from the ongoing call chip.

        // GIVEN layout the status bar window fullscreen portrait
        whenever(sbWindowController.addViewToWindow(any(), any())).then {
            val statusbarFake = FrameLayout(mContext)
            statusbarFake.layout(
                fullScreenSb.left,
                fullScreenSb.top,
                fullScreenSb.right,
                fullScreenSb.bottom,
            )

            val lp = it.arguments[1] as FrameLayout.LayoutParams
            assertThat(lp.gravity and Gravity.VERTICAL_GRAVITY_MASK).isEqualTo(Gravity.TOP)

            statusbarFake.addView(
                it.arguments[0] as View,
                lp,
            )
        }

        // GIVEN insets provider gives the correct content area
        whenever(insetsProvider.getStatusBarContentAreaForCurrentRotation())
            .thenReturn(portraitArea)

        // WHEN the controller lays out the chip in a fullscreen window
        controller.prepareChipAnimation(viewCreator)

        // THEN it still aligns the chip to the content area provided by the insets provider
        val chipRect = controller.chipBounds
        assertThat(chipRect).isEqualTo(Rect(890, 25, 990, 75))
    }

    class TestView(context: Context) : View(context), BackgroundAnimatableView {
        override val view: View
            get() = this

        override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
            setMeasuredDimension(100, 50)
        }

        override fun setBoundsForAnimation(l: Int, t: Int, r: Int, b: Int) {
            setLeftTopRightBottom(l, t, r, b)
        }
    }

    private fun getInsetsListener(): StatusBarContentInsetsChangedListener {
        val callbackCaptor = argumentCaptor<StatusBarContentInsetsChangedListener>()
        verify(insetsProvider).addCallback(capture(callbackCaptor))
        return callbackCaptor.value!!
    }

    companion object {
        private val portraitArea = Rect(10, 0, 990, 100)
        private val landscapeArea = Rect(10, 0, 1990, 80)
        private val fullScreenSb = Rect(10, 0, 990, 2000)

        // 10px insets on both sides
        private const val insets = 10
    }
}