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

Commit e47005c8 authored by Daniel Norman's avatar Daniel Norman
Browse files

feat(edt): Use sampled lightness for Status Bar

This is currently enabled only when the user has enabled the dark theme
accessibility feature that inverts certain light theme apps to an
automatically-generated dark theme.

Fix: 379760792
Bug: 365120736
Flag: com.android.systemui.status_bar_region_sampling
Test: StatusBarModeRepositoryImplTest
Test: StatusBarRegionSamplingInteractorTest
Test: StatusBarRegionSamplingViewModelTest
Change-Id: Idf6d34840ee2bc9bca1ca0d0c44ad930ed6989cb
parent 5f38d5ad
Loading
Loading
Loading
Loading
+7 −0
Original line number Original line Diff line number Diff line
@@ -65,6 +65,13 @@ flag {
    }
    }
}
}


flag {
    name: "status_bar_region_sampling"
    namespace: "accessibility"
    description: "Uses region sampling in the Status Bar to avoid contrast bugs in the status bar when expanded dark theme is enabled"
    bug: "379760792"
}

flag {
flag {
    name: "update_corner_radius_on_display_changed"
    name: "update_corner_radius_on_display_changed"
    namespace: "accessibility"
    namespace: "accessibility"
+53 −0
Original line number Original line Diff line number Diff line
@@ -32,6 +32,7 @@ import com.android.internal.view.AppearanceRegion
import com.android.systemui.SysuiTestCase
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.CommandQueue
import com.android.systemui.statusbar.StatusBarRegionSampling
import com.android.systemui.statusbar.core.StatusBarRootModernization
import com.android.systemui.statusbar.core.StatusBarRootModernization
import com.android.systemui.statusbar.data.model.StatusBarMode
import com.android.systemui.statusbar.data.model.StatusBarMode
import com.android.systemui.statusbar.layout.BoundsPair
import com.android.systemui.statusbar.layout.BoundsPair
@@ -401,6 +402,54 @@ class StatusBarModeRepositoryImplTest : SysuiTestCase() {
                .isEqualTo(newLetterboxAppearance.appearanceRegions)
                .isEqualTo(newLetterboxAppearance.appearanceRegions)
        }
        }


    @Test
    @EnableFlags(StatusBarRegionSampling.FLAG_NAME)
    fun statusBarAppearance_sampledAvailable_usesSampledAppearance() =
        testScope.runTest {
            val latest by collectLastValue(underTest.statusBarAppearance)

            underTest.setSampledAppearanceRegions(SAMPLED_APPEARANCE_REGIONS)
            onSystemBarAttributesChanged(
                appearance = APPEARANCE,
                appearanceRegions = APPEARANCE_REGIONS.toTypedArray(),
                letterboxDetails = emptyArray(),
            )

            assertThat(latest!!.appearanceRegions).isEqualTo(SAMPLED_APPEARANCE_REGIONS)
        }

    @Test
    @DisableFlags(StatusBarRegionSampling.FLAG_NAME)
    fun statusBarAppearance_sampledAvailable_flagDisabled_usesDisplayPolicyProvidedAppearance() =
        testScope.runTest {
            val latest by collectLastValue(underTest.statusBarAppearance)

            underTest.setSampledAppearanceRegions(SAMPLED_APPEARANCE_REGIONS)
            onSystemBarAttributesChanged(
                appearance = APPEARANCE,
                appearanceRegions = APPEARANCE_REGIONS.toTypedArray(),
                letterboxDetails = emptyArray(),
            )

            assertThat(latest!!.appearanceRegions).isEqualTo(APPEARANCE_REGIONS)
        }

    @Test
    @EnableFlags(StatusBarRegionSampling.FLAG_NAME)
    fun statusBarAppearance_sampledUnavailable_usesDisplayPolicyProvidedAppearance() =
        testScope.runTest {
            val latest by collectLastValue(underTest.statusBarAppearance)

            underTest.setSampledAppearanceRegions(listOf())
            onSystemBarAttributesChanged(
                appearance = APPEARANCE,
                appearanceRegions = APPEARANCE_REGIONS.toTypedArray(),
                letterboxDetails = emptyArray(),
            )

            assertThat(latest!!.appearanceRegions).isEqualTo(APPEARANCE_REGIONS)
        }

    @Test
    @Test
    @DisableChipsModernization
    @DisableChipsModernization
    fun statusBarMode_ongoingCallAndFullscreen_semiTransparent() =
    fun statusBarMode_ongoingCallAndFullscreen_semiTransparent() =
@@ -578,6 +627,10 @@ class StatusBarModeRepositoryImplTest : SysuiTestCase() {
        private const val APPEARANCE = APPEARANCE_LIGHT_STATUS_BARS
        private const val APPEARANCE = APPEARANCE_LIGHT_STATUS_BARS
        private val APPEARANCE_REGION = AppearanceRegion(APPEARANCE, Rect(0, 0, 150, 300))
        private val APPEARANCE_REGION = AppearanceRegion(APPEARANCE, Rect(0, 0, 150, 300))
        private val APPEARANCE_REGIONS = listOf(APPEARANCE_REGION)
        private val APPEARANCE_REGIONS = listOf(APPEARANCE_REGION)
        private const val APPEARANCE_DARK = 0
        private val SAMPLED_APPEARANCE_REGION =
            AppearanceRegion(APPEARANCE_DARK, Rect(0, 0, 150, 300))
        private val SAMPLED_APPEARANCE_REGIONS = listOf(SAMPLED_APPEARANCE_REGION)
        private val LETTERBOX_DETAILS =
        private val LETTERBOX_DETAILS =
            listOf(
            listOf(
                LetterboxDetails(
                LetterboxDetails(
+104 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2025 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.domain.interactor

import android.graphics.Rect
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import android.view.Display
import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.view.AppearanceRegion
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runTest
import com.android.systemui.statusbar.StatusBarRegionSampling
import com.android.systemui.statusbar.data.repository.fakeStatusBarModeRepository
import com.android.systemui.testKosmos
import com.android.systemui.uimode.data.repository.fakeForceInvertRepository
import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class StatusBarRegionSamplingInteractorTest : SysuiTestCase() {
    private val kosmos = testKosmos()

    private val Kosmos.underTest by Kosmos.Fixture { kosmos.statusBarRegionSamplingInteractor }

    @Test
    fun isRegionSamplingEnabled_forceInvertOff_returnsFalse() =
        kosmos.runTest {
            val latest by collectLastValue(underTest.isRegionSamplingEnabled)

            kosmos.fakeForceInvertRepository.setForceInvertDark(false)

            assertThat(latest).isFalse()
        }

    @Test
    @EnableFlags(StatusBarRegionSampling.FLAG_NAME)
    fun isRegionSamplingEnabled_forceInvertDark_flagEnabled_returnsTrue() =
        kosmos.runTest {
            val latest by collectLastValue(underTest.isRegionSamplingEnabled)

            kosmos.fakeForceInvertRepository.setForceInvertDark(true)

            assertThat(latest).isTrue()
        }

    @Test
    @DisableFlags(StatusBarRegionSampling.FLAG_NAME)
    fun isRegionSamplingEnabled_forceInvertDark_flagDisabled_returnsFalse() =
        kosmos.runTest {
            val latest by collectLastValue(underTest.isRegionSamplingEnabled)

            kosmos.fakeForceInvertRepository.setForceInvertDark(true)

            assertThat(latest).isFalse()
        }

    @Test
    @EnableFlags(StatusBarRegionSampling.FLAG_NAME)
    fun setSampledAppearanceRegions_propagatesNonNullToStatusBarModeRepository() =
        kosmos.runTest {
            val firstRegion = AppearanceRegion(0, Rect())
            val secondRegion = null
            val thirdRegion = AppearanceRegion(APPEARANCE_LIGHT_STATUS_BARS, Rect())
            val appearanceRegions = listOf(firstRegion, secondRegion, thirdRegion)

            underTest.setSampledAppearanceRegions(Display.DEFAULT_DISPLAY, appearanceRegions)

            assertThat(fakeStatusBarModeRepository.defaultDisplay.fakeSampledAppearanceRegions)
                .containsExactly(firstRegion, thirdRegion)
        }

    @Test
    @DisableFlags(StatusBarRegionSampling.FLAG_NAME)
    fun setSampledAppearanceRegions_flagDisabled_doesNothing() =
        kosmos.runTest {
            val region = AppearanceRegion(0, Rect())

            underTest.setSampledAppearanceRegions(Display.DEFAULT_DISPLAY, listOf(region))

            assertThat(fakeStatusBarModeRepository.defaultDisplay.fakeSampledAppearanceRegions)
                .isNull()
        }
}
+252 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2025 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.ui.viewmodel

import android.graphics.Rect
import android.platform.test.annotations.EnableFlags
import android.view.View
import android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.view.AppearanceRegion
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.statusbar.StatusBarRegionSampling
import com.android.systemui.statusbar.domain.interactor.fakeStatusBarRegionSamplingInteractor
import com.android.systemui.statusbar.ui.viewmodel.StatusBarRegionSamplingViewModel.RegionSamplingHelperFactory.Purpose
import com.android.systemui.testKosmos
import com.android.wm.shell.shared.handles.RegionSamplingHelper
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.Job
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.atLeastOnce
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
@EnableFlags(StatusBarRegionSampling.FLAG_NAME)
class StatusBarRegionSamplingViewModelTest : SysuiTestCase() {
    private val activationJob = Job()

    private val startContainerBounds = Rect(0, 20, 500, 100)
    private val startIconBounds = Rect(100, 30, 200, 60)
    private val endContainerBounds = Rect(500, 20, 1000, 100)
    private val endIconBounds = Rect(600, 30, 700, 60)
    // Sampling bounds of each side should be the same as containerBounds, but with the
    // top equal to the corresponding side's iconBounds.bottom.
    private val expectedStartSideSamplingBounds = Rect(0, 60, 500, 100)
    private val expectedEndSideSamplingBounds = Rect(500, 60, 1000, 100)
    // AppearanceRegion bounds of each side should be the same as containerBounds, but with
    // the top equal to 0.
    private val expectedStartSideAppearanceRegionBounds = Rect(0, 0, 500, 100)
    private val expectedEndSideAppearanceRegionBounds = Rect(500, 0, 1000, 100)

    private val kosmos =
        testKosmos().useUnconfinedTestDispatcher().apply {
            mockStatusBarStartSideContainerView = mockViewWithBounds(startContainerBounds)
            mockStatusBarStartSideIconView = mockViewWithBounds(startIconBounds)
            mockStatusBarEndSideContainerView = mockViewWithBounds(endContainerBounds)
            mockStatusBarEndSideIconView = mockViewWithBounds(endIconBounds)
        }

    private fun mockViewWithBounds(bounds: Rect): View {
        return mock<View> {
            on { getBoundsOnScreen(any()) } doAnswer
                {
                    val boundsOutput = it.arguments[0] as Rect
                    boundsOutput.set(bounds)
                    return@doAnswer
                }
        }
    }

    private fun View.triggerOnLayoutChange() {
        val captor = argumentCaptor<View.OnLayoutChangeListener>()
        verify(this, atLeastOnce()).addOnLayoutChangeListener(captor.capture())
        captor.allValues.forEach { it.onLayoutChange(this, 0, 0, 0, 0, 0, 0, 0, 0) }
    }

    private val Kosmos.underTest by Kosmos.Fixture { kosmos.statusBarRegionSamplingViewModel }

    @Before
    fun setUp() {
        kosmos.underTest.activateIn(kosmos.testScope, activationJob)
    }

    @Test
    fun regionSamplingDisabled_doesNotRegisterRegionSamplingHelpers() =
        kosmos.runTest {
            fakeStatusBarRegionSamplingInteractor.setRegionSamplingEnabled(false)

            verify(mockRegionSamplingHelperFactory, never())
                .create(any(), any(), any(), any(), any())
        }

    @Test
    fun regionSamplingEnabled_afterActivationCancelled_doesNotRegisterRegionSamplingHelpers() =
        kosmos.runTest {
            activationJob.cancel()
            fakeStatusBarRegionSamplingInteractor.setRegionSamplingEnabled(true)

            verify(mockRegionSamplingHelperFactory, never())
                .create(any(), any(), any(), any(), any())
        }

    @Test
    fun regionSamplingBecomesDisabled_stopsRegionSamplingHelpers_setsEmptySampledRegions() =
        kosmos.runTest {
            val mockStartRegionSamplingHelper = mock<RegionSamplingHelper>()
            val mockEndRegionSamplingHelper = mock<RegionSamplingHelper>()
            whenever(
                    mockRegionSamplingHelperFactory.create(
                        any(),
                        any(),
                        any(),
                        any(),
                        eq(Purpose.START_SIDE),
                    )
                )
                .thenReturn(mockStartRegionSamplingHelper)
            whenever(
                    mockRegionSamplingHelperFactory.create(
                        any(),
                        any(),
                        any(),
                        any(),
                        eq(Purpose.END_SIDE),
                    )
                )
                .thenReturn(mockEndRegionSamplingHelper)
            fakeStatusBarRegionSamplingInteractor.setRegionSamplingEnabled(true)

            fakeStatusBarRegionSamplingInteractor.setRegionSamplingEnabled(false)

            verify(mockStartRegionSamplingHelper).stop()
            verify(mockEndRegionSamplingHelper).stop()
            assertThat(fakeStatusBarRegionSamplingInteractor.sampledAppearanceRegions).isEmpty()
        }

    @Test
    fun sampledRegion_usesSamplingBoundsFromContainerAndIconBounds() =
        kosmos.runTest {
            fakeStatusBarRegionSamplingInteractor.setRegionSamplingEnabled(true)
            mockStatusBarStartSideContainerView.triggerOnLayoutChange()
            mockStatusBarEndSideContainerView.triggerOnLayoutChange()

            val startSideSamplingBounds =
                getRegionSamplingHelperCallback(Purpose.START_SIDE)
                    .getSampledRegion(mockStatusBarAttachStateView)
            val endSideSamplingBounds =
                getRegionSamplingHelperCallback(Purpose.END_SIDE)
                    .getSampledRegion(mockStatusBarAttachStateView)

            assertThat(startSideSamplingBounds).isEqualTo(expectedStartSideSamplingBounds)
            assertThat(endSideSamplingBounds).isEqualTo(expectedEndSideSamplingBounds)
        }

    @Test
    fun onRegionDarknessChanged_isRegionDarkTrue_setAppearanceDarkStatusBars() =
        kosmos.runTest {
            fakeStatusBarRegionSamplingInteractor.setRegionSamplingEnabled(true)
            mockStatusBarStartSideContainerView.triggerOnLayoutChange()
            mockStatusBarEndSideContainerView.triggerOnLayoutChange()

            getRegionSamplingHelperCallback(Purpose.START_SIDE).onRegionDarknessChanged(true)
            getRegionSamplingHelperCallback(Purpose.END_SIDE).onRegionDarknessChanged(true)

            val startSideAppearanceRegion = getSampledAppearanceRegion(Purpose.START_SIDE)
            assertThat(startSideAppearanceRegion.appearance and APPEARANCE_LIGHT_STATUS_BARS)
                .isEqualTo(0)
            val endSideAppearanceRegion = getSampledAppearanceRegion(Purpose.END_SIDE)
            assertThat(endSideAppearanceRegion.appearance and APPEARANCE_LIGHT_STATUS_BARS)
                .isEqualTo(0)
        }

    @Test
    fun onRegionDarknessChanged_isRegionDarkFalse_setsAppearanceLightStatusBars() =
        kosmos.runTest {
            fakeStatusBarRegionSamplingInteractor.setRegionSamplingEnabled(true)
            mockStatusBarStartSideContainerView.triggerOnLayoutChange()
            mockStatusBarEndSideContainerView.triggerOnLayoutChange()

            getRegionSamplingHelperCallback(Purpose.START_SIDE).onRegionDarknessChanged(false)
            getRegionSamplingHelperCallback(Purpose.END_SIDE).onRegionDarknessChanged(false)

            val startSideAppearanceRegion = getSampledAppearanceRegion(Purpose.START_SIDE)
            assertThat(startSideAppearanceRegion.appearance and APPEARANCE_LIGHT_STATUS_BARS)
                .isEqualTo(APPEARANCE_LIGHT_STATUS_BARS)
            val endSideAppearanceRegion = getSampledAppearanceRegion(Purpose.END_SIDE)
            assertThat(endSideAppearanceRegion.appearance and APPEARANCE_LIGHT_STATUS_BARS)
                .isEqualTo(APPEARANCE_LIGHT_STATUS_BARS)
        }

    @Test
    fun onRegionDarknessChanged_isRegionDarkMixed_setsAppearanceMixedStatusBars() =
        kosmos.runTest {
            fakeStatusBarRegionSamplingInteractor.setRegionSamplingEnabled(true)
            mockStatusBarStartSideContainerView.triggerOnLayoutChange()
            mockStatusBarEndSideContainerView.triggerOnLayoutChange()

            getRegionSamplingHelperCallback(Purpose.START_SIDE).onRegionDarknessChanged(false)
            getRegionSamplingHelperCallback(Purpose.END_SIDE).onRegionDarknessChanged(true)

            val startSideAppearanceRegion = getSampledAppearanceRegion(Purpose.START_SIDE)
            assertThat(startSideAppearanceRegion.appearance and APPEARANCE_LIGHT_STATUS_BARS)
                .isEqualTo(APPEARANCE_LIGHT_STATUS_BARS)
            val endSideAppearanceRegion = getSampledAppearanceRegion(Purpose.END_SIDE)
            assertThat(endSideAppearanceRegion.appearance and APPEARANCE_LIGHT_STATUS_BARS)
                .isEqualTo(0)
        }

    private fun Kosmos.getRegionSamplingHelperCallback(
        purpose: Purpose
    ): RegionSamplingHelper.SamplingCallback {
        val captor = argumentCaptor<RegionSamplingHelper.SamplingCallback>()
        verify(mockRegionSamplingHelperFactory)
            .create(any(), captor.capture(), any(), any(), eq(purpose))
        return captor.lastValue
    }

    private fun Kosmos.getSampledAppearanceRegion(purpose: Purpose): AppearanceRegion {
        val appearanceRegions = fakeStatusBarRegionSamplingInteractor.sampledAppearanceRegions!!
        val expectedBounds =
            when (purpose) {
                Purpose.START_SIDE -> expectedStartSideAppearanceRegionBounds
                Purpose.END_SIDE -> expectedEndSideAppearanceRegionBounds
            }
        val result = appearanceRegions.filter { it!!.bounds == expectedBounds }
        assertWithMessage("Unable to find region with bounds $expectedBounds in $appearanceRegions")
            .that(result)
            .hasSize(1)
        return result[0]!!
    }
}
+2 −0
Original line number Original line Diff line number Diff line
@@ -132,6 +132,7 @@ import com.android.systemui.statusbar.chips.StatusBarChipsModule;
import com.android.systemui.statusbar.connectivity.ConnectivityModule;
import com.android.systemui.statusbar.connectivity.ConnectivityModule;
import com.android.systemui.statusbar.dagger.StatusBarModule;
import com.android.systemui.statusbar.dagger.StatusBarModule;
import com.android.systemui.statusbar.disableflags.dagger.DisableFlagsModule;
import com.android.systemui.statusbar.disableflags.dagger.DisableFlagsModule;
import com.android.systemui.statusbar.domain.interactor.StatusBarRegionSamplingInteractorModule;
import com.android.systemui.statusbar.events.StatusBarEventsModule;
import com.android.systemui.statusbar.events.StatusBarEventsModule;
import com.android.systemui.statusbar.events.SystemStatusAnimationScheduler;
import com.android.systemui.statusbar.events.SystemStatusAnimationScheduler;
import com.android.systemui.statusbar.featurepods.av.AvControlsChipModule;
import com.android.systemui.statusbar.featurepods.av.AvControlsChipModule;
@@ -283,6 +284,7 @@ import javax.inject.Named;
        StatusBarChipsModule.class,
        StatusBarChipsModule.class,
        StatusBarPipelineModule.class,
        StatusBarPipelineModule.class,
        StatusBarPolicyModule.class,
        StatusBarPolicyModule.class,
        StatusBarRegionSamplingInteractorModule.class,
        StatusBarViewBinderModule.class,
        StatusBarViewBinderModule.class,
        StatusBarWindowModule.class,
        StatusBarWindowModule.class,
        SystemPropertiesFlagsModule.class,
        SystemPropertiesFlagsModule.class,
Loading