[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
This commit is contained in:
Evan Laird
2023-06-30 17:43:38 -04:00
parent b1aa946049
commit 62a8d73e6c
2 changed files with 250 additions and 23 deletions

View File

@@ -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 =

View File

@@ -0,0 +1,189 @@
/*
* 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
}
}