diff --git a/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeader.java b/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeader.java index 946fe542741fa..e696d131267f8 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeader.java +++ b/packages/SystemUI/src/com/android/systemui/qs/QuickStatusBarHeader.java @@ -152,9 +152,7 @@ public class QuickStatusBarHeader extends FrameLayout { Configuration config = mContext.getResources().getConfiguration(); setDatePrivacyContainersWidth(config.orientation == Configuration.ORIENTATION_LANDSCAPE); - // QS will always show the estimate, and BatteryMeterView handles the case where - // it's unavailable or charging - mBatteryRemainingIcon.setPercentShowMode(BatteryMeterView.MODE_ESTIMATE); + updateBatteryMode(); mIconsAlphaAnimatorFixed = new TouchAnimator.Builder() .addFloat(mIconContainer, "alpha", 0, 1) @@ -460,24 +458,24 @@ public class QuickStatusBarHeader extends FrameLayout { (LinearLayout.LayoutParams) mDatePrivacySeparator.getLayoutParams(); LinearLayout.LayoutParams mClockIconsSeparatorLayoutParams = (LinearLayout.LayoutParams) mClockIconsSeparator.getLayoutParams(); - if (cutout != null) { - Rect topCutout = cutout.getBoundingRectTop(); - if (topCutout.isEmpty() || hasCornerCutout) { - datePrivacySeparatorLayoutParams.width = 0; - mDatePrivacySeparator.setVisibility(View.GONE); - mClockIconsSeparatorLayoutParams.width = 0; - setSeparatorVisibility(false); - mShowClockIconsSeparator = false; - mHasCenterCutout = false; - } else { - datePrivacySeparatorLayoutParams.width = topCutout.width(); - mDatePrivacySeparator.setVisibility(View.VISIBLE); - mClockIconsSeparatorLayoutParams.width = topCutout.width(); - mShowClockIconsSeparator = true; - setSeparatorVisibility(mKeyguardExpansionFraction == 0f); - mHasCenterCutout = true; - } + + Rect topCutout = cutout == null ? null : cutout.getBoundingRectTop(); + if (topCutout == null || topCutout.isEmpty() || hasCornerCutout) { + datePrivacySeparatorLayoutParams.width = 0; + mDatePrivacySeparator.setVisibility(View.GONE); + mClockIconsSeparatorLayoutParams.width = 0; + setSeparatorVisibility(false); + mShowClockIconsSeparator = false; + mHasCenterCutout = false; + } else { + datePrivacySeparatorLayoutParams.width = topCutout.width(); + mDatePrivacySeparator.setVisibility(View.VISIBLE); + mClockIconsSeparatorLayoutParams.width = topCutout.width(); + mShowClockIconsSeparator = true; + setSeparatorVisibility(mKeyguardExpansionFraction == 0f); + mHasCenterCutout = true; } + mDatePrivacySeparator.setLayoutParams(datePrivacySeparatorLayoutParams); mClockIconsSeparator.setLayoutParams(mClockIconsSeparatorLayoutParams); mCutOutPaddingLeft = sbInsets.first; diff --git a/packages/SystemUI/src/com/android/systemui/shade/LargeScreenShadeHeaderController.kt b/packages/SystemUI/src/com/android/systemui/shade/LargeScreenShadeHeaderController.kt index 197232ecb5475..80f7c36bdd8ec 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/LargeScreenShadeHeaderController.kt +++ b/packages/SystemUI/src/com/android/systemui/shade/LargeScreenShadeHeaderController.kt @@ -24,6 +24,7 @@ import android.os.Bundle import android.os.Trace import android.os.Trace.TRACE_TAG_APP import android.util.Pair +import android.view.DisplayCutout import android.view.View import android.view.WindowInsets import android.widget.TextView @@ -91,7 +92,8 @@ class LargeScreenShadeHeaderController @Inject constructor( private val featureFlags: FeatureFlags, private val qsCarrierGroupControllerBuilder: QSCarrierGroupController.Builder, private val combinedShadeHeadersConstraintManager: CombinedShadeHeadersConstraintManager, - private val demoModeController: DemoModeController + private val demoModeController: DemoModeController, + private val qsBatteryModeController: QsBatteryModeController, ) : ViewController(header), Dumpable { companion object { @@ -129,9 +131,8 @@ class LargeScreenShadeHeaderController @Inject constructor( private val iconContainer: StatusIconContainer = header.findViewById(R.id.statusIcons) private val qsCarrierGroup: QSCarrierGroup = header.findViewById(R.id.carrier_group) - private var cutoutLeft = 0 - private var cutoutRight = 0 private var roundedCorners = 0 + private var cutout: DisplayCutout? = null private var lastInsets: WindowInsets? = null private var qsDisabled = false @@ -273,7 +274,6 @@ class LargeScreenShadeHeaderController @Inject constructor( // battery settings same as in QS icons batteryMeterViewController.ignoreTunerUpdates() - batteryIcon.setPercentShowMode(BatteryMeterView.MODE_ESTIMATE) iconManager = tintedIconManagerFactory.create(iconContainer, StatusBarLocation.QS) iconManager.setTint( @@ -305,6 +305,7 @@ class LargeScreenShadeHeaderController @Inject constructor( if (header is MotionLayout) { header.setOnApplyWindowInsetsListener(insetListener) + clock.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> val newPivot = if (v.isLayoutRtl) v.width.toFloat() else 0f v.pivotX = newPivot @@ -376,11 +377,13 @@ class LargeScreenShadeHeaderController @Inject constructor( } private fun updateConstraintsForInsets(view: MotionLayout, insets: WindowInsets) { - val cutout = insets.displayCutout + val cutout = insets.displayCutout.also { + this.cutout = it + } val sbInsets: Pair = insetsProvider.getStatusBarContentInsetsForCurrentRotation() - cutoutLeft = sbInsets.first - cutoutRight = sbInsets.second + val cutoutLeft = sbInsets.first + val cutoutRight = sbInsets.second val hasCornerCutout: Boolean = insetsProvider.currentRotationHasCornerCutout() updateQQSPaddings() // Set these guides as the left/right limits for content that lives in the top row, using @@ -408,6 +411,13 @@ class LargeScreenShadeHeaderController @Inject constructor( } view.updateAllConstraints(changes) + updateBatteryMode() + } + + private fun updateBatteryMode() { + qsBatteryModeController.getBatteryMode(cutout, qsExpandedFraction)?.let { + batteryIcon.setPercentShowMode(it) + } } private fun updateScrollY() { @@ -475,6 +485,7 @@ class LargeScreenShadeHeaderController @Inject constructor( if (header is MotionLayout && !largeScreenActive && visible) { logInstantEvent("updatePosition: $qsExpandedFraction") header.progress = qsExpandedFraction + updateBatteryMode() } } @@ -511,6 +522,7 @@ class LargeScreenShadeHeaderController @Inject constructor( val padding = resources.getDimensionPixelSize(R.dimen.qs_panel_padding) header.setPadding(padding, header.paddingTop, padding, header.paddingBottom) updateQQSPaddings() + qsBatteryModeController.updateResources() } private fun updateQQSPaddings() { diff --git a/packages/SystemUI/src/com/android/systemui/shade/QsBatteryModeController.kt b/packages/SystemUI/src/com/android/systemui/shade/QsBatteryModeController.kt new file mode 100644 index 0000000000000..3eec7fa0e84a6 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/shade/QsBatteryModeController.kt @@ -0,0 +1,70 @@ +package com.android.systemui.shade + +import android.content.Context +import android.view.DisplayCutout +import com.android.systemui.R +import com.android.systemui.battery.BatteryMeterView +import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider +import javax.inject.Inject + +/** + * Controls [BatteryMeterView.BatteryPercentMode]. It takes into account cutout and qs-qqs + * transition fraction when determining the mode. + */ +class QsBatteryModeController +@Inject +constructor( + private val context: Context, + private val insetsProvider: StatusBarContentInsetsProvider, +) { + + private companion object { + // MotionLayout frames are in [0, 100]. Where 0 and 100 are reserved for start and end + // frames. + const val MOTION_LAYOUT_MAX_FRAME = 100 + // We add a single buffer frame to ensure that battery view faded out completely when we are + // about to change it's state + const val BUFFER_FRAME_COUNT = 1 + } + + private var fadeInStartFraction: Float = 0f + private var fadeOutCompleteFraction: Float = 0f + + init { + updateResources() + } + + /** + * Returns an appropriate [BatteryMeterView.BatteryPercentMode] for the [qsExpandedFraction] and + * [cutout]. We don't show battery estimation in qqs header on the devices with center cutout. + * The result might be null when the battery icon is invisible during the qs-qqs transition + * animation. + */ + @BatteryMeterView.BatteryPercentMode + fun getBatteryMode(cutout: DisplayCutout?, qsExpandedFraction: Float): Int? = + when { + qsExpandedFraction > fadeInStartFraction -> BatteryMeterView.MODE_ESTIMATE + qsExpandedFraction < fadeOutCompleteFraction -> + if (hasCenterCutout(cutout)) { + BatteryMeterView.MODE_ON + } else { + BatteryMeterView.MODE_ESTIMATE + } + else -> null + } + + fun updateResources() { + fadeInStartFraction = + (context.resources.getInteger(R.integer.fade_in_start_frame) - BUFFER_FRAME_COUNT) / + MOTION_LAYOUT_MAX_FRAME.toFloat() + fadeOutCompleteFraction = + (context.resources.getInteger(R.integer.fade_out_complete_frame) + BUFFER_FRAME_COUNT) / + MOTION_LAYOUT_MAX_FRAME.toFloat() + } + + private fun hasCenterCutout(cutout: DisplayCutout?): Boolean = + cutout?.let { + !insetsProvider.currentRotationHasCornerCutout() && !it.boundingRectTop.isEmpty + } + ?: false +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/LargeScreenShadeHeaderControllerCombinedTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/LargeScreenShadeHeaderControllerCombinedTest.kt index 91fef1daaf86f..ee5f61c835da8 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/LargeScreenShadeHeaderControllerCombinedTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/LargeScreenShadeHeaderControllerCombinedTest.kt @@ -21,6 +21,7 @@ import android.content.res.Resources import android.content.res.XmlResourceParser import android.graphics.Rect import android.testing.AndroidTestingRunner +import android.view.Display import android.view.DisplayCutout import android.view.View import android.view.ViewPropertyAnimator @@ -77,9 +78,11 @@ import org.mockito.Mockito.clearInvocations import org.mockito.Mockito.inOrder import org.mockito.Mockito.never import org.mockito.Mockito.reset +import org.mockito.Mockito.same +import org.mockito.Mockito.spy import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` as whenever import org.mockito.junit.MockitoJUnit +import org.mockito.Mockito.`when` as whenever private val EMPTY_CHANGES = ConstraintsChanges() @@ -133,6 +136,7 @@ class LargeScreenShadeHeaderControllerCombinedTest : SysuiTestCase() { @Mock private lateinit var mockedContext: Context + private lateinit var viewContext: Context @Mock(answer = Answers.RETURNS_MOCKS) private lateinit var view: MotionLayout @@ -143,6 +147,7 @@ class LargeScreenShadeHeaderControllerCombinedTest : SysuiTestCase() { @Mock private lateinit var largeScreenConstraints: ConstraintSet @Mock private lateinit var demoModeController: DemoModeController + @Mock private lateinit var qsBatteryModeController: QsBatteryModeController @JvmField @Rule val mockitoRule = MockitoJUnit.rule() @@ -175,7 +180,8 @@ class LargeScreenShadeHeaderControllerCombinedTest : SysuiTestCase() { .thenReturn(qsCarrierGroupControllerBuilder) whenever(qsCarrierGroupControllerBuilder.build()).thenReturn(qsCarrierGroupController) - whenever(view.context).thenReturn(context) + viewContext = spy(context) + whenever(view.context).thenReturn(viewContext) whenever(view.resources).thenReturn(context.resources) whenever(view.setVisibility(ArgumentMatchers.anyInt())).then { viewVisibility = it.arguments[0] as Int @@ -192,19 +198,20 @@ class LargeScreenShadeHeaderControllerCombinedTest : SysuiTestCase() { setUpMotionLayout(view) controller = LargeScreenShadeHeaderController( - view, - statusBarIconController, - iconManagerFactory, - privacyIconsController, - insetsProvider, - configurationController, - variableDateViewControllerFactory, - batteryMeterViewController, - dumpManager, - featureFlags, - qsCarrierGroupControllerBuilder, - combinedShadeHeadersConstraintManager, - demoModeController + view, + statusBarIconController, + iconManagerFactory, + privacyIconsController, + insetsProvider, + configurationController, + variableDateViewControllerFactory, + batteryMeterViewController, + dumpManager, + featureFlags, + qsCarrierGroupControllerBuilder, + combinedShadeHeadersConstraintManager, + demoModeController, + qsBatteryModeController, ) whenever(view.isAttachedToWindow).thenReturn(true) controller.init() @@ -218,13 +225,29 @@ class LargeScreenShadeHeaderControllerCombinedTest : SysuiTestCase() { verify(batteryMeterViewController).init() verify(batteryMeterViewController).ignoreTunerUpdates() - verify(batteryMeterView).setPercentShowMode(BatteryMeterView.MODE_ESTIMATE) val inOrder = inOrder(qsCarrierGroupControllerBuilder) inOrder.verify(qsCarrierGroupControllerBuilder).setQSCarrierGroup(carrierGroup) inOrder.verify(qsCarrierGroupControllerBuilder).build() } + @Test + fun `battery mode controller called when qsExpandedFraction changes`() { + whenever(qsBatteryModeController.getBatteryMode(same(null), eq(0f))) + .thenReturn(BatteryMeterView.MODE_ON) + whenever(qsBatteryModeController.getBatteryMode(same(null), eq(1f))) + .thenReturn(BatteryMeterView.MODE_ESTIMATE) + controller.qsVisible = true + + val times = 10 + repeat(times) { + controller.qsExpandedFraction = it / (times - 1).toFloat() + } + + verify(batteryMeterView).setPercentShowMode(BatteryMeterView.MODE_ON) + verify(batteryMeterView).setPercentShowMode(BatteryMeterView.MODE_ESTIMATE) + } + @Test fun testClockPivotLtr() { val width = 200 @@ -684,11 +707,11 @@ class LargeScreenShadeHeaderControllerCombinedTest : SysuiTestCase() { configurationController.notifyDensityOrFontScaleChanged() val captor = ArgumentCaptor.forClass(XmlResourceParser::class.java) - verify(qqsConstraints).load(eq(context), capture(captor)) + verify(qqsConstraints).load(eq(viewContext), capture(captor)) assertThat(captor.value.getResId()).isEqualTo(R.xml.qqs_header) - verify(qsConstraints).load(eq(context), capture(captor)) + verify(qsConstraints).load(eq(viewContext), capture(captor)) assertThat(captor.value.getResId()).isEqualTo(R.xml.qs_header) - verify(largeScreenConstraints).load(eq(context), capture(captor)) + verify(largeScreenConstraints).load(eq(viewContext), capture(captor)) assertThat(captor.value.getResId()).isEqualTo(R.xml.large_screen_shade_header) } @@ -786,6 +809,13 @@ class LargeScreenShadeHeaderControllerCombinedTest : SysuiTestCase() { whenever(insetsProvider.getStatusBarContentInsetsForCurrentRotation()) .thenReturn(Pair(0, 0).toAndroidPair()) whenever(insetsProvider.currentRotationHasCornerCutout()).thenReturn(false) + setupCurrentInsets(null) + } + + private fun setupCurrentInsets(cutout: DisplayCutout?) { + val mockedDisplay = + mock().also { display -> whenever(display.cutout).thenReturn(cutout) } + whenever(viewContext.display).thenReturn(mockedDisplay) } private fun Pair.toAndroidPair(): android.util.Pair { diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/LargeScreenShadeHeaderControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/LargeScreenShadeHeaderControllerTest.kt index 2bf2a81fe13ee..6175df9b5026b 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/LargeScreenShadeHeaderControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/LargeScreenShadeHeaderControllerTest.kt @@ -76,6 +76,7 @@ class LargeScreenShadeHeaderControllerTest : SysuiTestCase() { @Mock private lateinit var mockedContext: Context @Mock private lateinit var demoModeController: DemoModeController + @Mock private lateinit var qsBatteryModeController: QsBatteryModeController @JvmField @Rule val mockitoRule = MockitoJUnit.rule() var viewVisibility = View.GONE @@ -130,8 +131,9 @@ class LargeScreenShadeHeaderControllerTest : SysuiTestCase() { featureFlags, qsCarrierGroupControllerBuilder, combinedShadeHeadersConstraintManager, - demoModeController - ) + demoModeController, + qsBatteryModeController, + ) whenever(view.isAttachedToWindow).thenReturn(true) mLargeScreenShadeHeaderController.init() carrierIconSlots = listOf( diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/QsBatteryModeControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/QsBatteryModeControllerTest.kt new file mode 100644 index 0000000000000..b547318a99b65 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/QsBatteryModeControllerTest.kt @@ -0,0 +1,100 @@ +package com.android.systemui.shade + +import android.content.Context +import android.content.res.Resources +import android.graphics.Rect +import android.testing.AndroidTestingRunner +import android.view.DisplayCutout +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.battery.BatteryMeterView +import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnit + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class QsBatteryModeControllerTest : SysuiTestCase() { + + private companion object { + val CENTER_TOP_CUTOUT: DisplayCutout = + mock().also { + whenever(it.boundingRectTop).thenReturn(Rect(10, 0, 20, 10)) + } + + const val MOTION_LAYOUT_MAX_FRAME = 100 + const val QQS_START_FRAME = 14 + const val QS_END_FRAME = 58 + } + + @JvmField @Rule val mockitoRule = MockitoJUnit.rule()!! + + @Mock private lateinit var insetsProvider: StatusBarContentInsetsProvider + @Mock private lateinit var mockedContext: Context + @Mock private lateinit var mockedResources: Resources + + private lateinit var controller: QsBatteryModeController // under test + + @Before + fun setup() { + whenever(mockedContext.resources).thenReturn(mockedResources) + whenever(mockedResources.getInteger(R.integer.fade_in_start_frame)).thenReturn(QS_END_FRAME) + whenever(mockedResources.getInteger(R.integer.fade_out_complete_frame)) + .thenReturn(QQS_START_FRAME) + + controller = QsBatteryModeController(mockedContext, insetsProvider) + } + + @Test + fun `returns MODE_ON for qqs with center cutout`() { + assertThat( + controller.getBatteryMode(CENTER_TOP_CUTOUT, QQS_START_FRAME.prevFrameToFraction()) + ) + .isEqualTo(BatteryMeterView.MODE_ON) + } + + @Test + fun `returns MODE_ESTIMATE for qs with center cutout`() { + assertThat(controller.getBatteryMode(CENTER_TOP_CUTOUT, QS_END_FRAME.nextFrameToFraction())) + .isEqualTo(BatteryMeterView.MODE_ESTIMATE) + } + + @Test + fun `returns MODE_ON for qqs with corner cutout`() { + whenever(insetsProvider.currentRotationHasCornerCutout()).thenReturn(true) + + assertThat( + controller.getBatteryMode(CENTER_TOP_CUTOUT, QQS_START_FRAME.prevFrameToFraction()) + ) + .isEqualTo(BatteryMeterView.MODE_ESTIMATE) + } + + @Test + fun `returns MODE_ESTIMATE for qs with corner cutout`() { + whenever(insetsProvider.currentRotationHasCornerCutout()).thenReturn(true) + + assertThat(controller.getBatteryMode(CENTER_TOP_CUTOUT, QS_END_FRAME.nextFrameToFraction())) + .isEqualTo(BatteryMeterView.MODE_ESTIMATE) + } + + @Test + fun `returns null in-between`() { + assertThat( + controller.getBatteryMode(CENTER_TOP_CUTOUT, QQS_START_FRAME.nextFrameToFraction()) + ) + .isNull() + assertThat(controller.getBatteryMode(CENTER_TOP_CUTOUT, QS_END_FRAME.prevFrameToFraction())) + .isNull() + } + + private fun Int.prevFrameToFraction(): Float = (this - 1) / MOTION_LAYOUT_MAX_FRAME.toFloat() + private fun Int.nextFrameToFraction(): Float = (this + 1) / MOTION_LAYOUT_MAX_FRAME.toFloat() +}