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

Commit ac3b07bc authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[SB][Chips] Define StatusBarBoundsViewModel to store view-related bounds

Bug: 397506207
Flag: com.android.systemui.status_bar_app_handle_tracking
Test: atest StatusBarBoundsViewModelTest
Change-Id: Icf647a56caf02097d6adcdfa56b63a61faa84113
parent bed07922
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
@@ -476,6 +476,17 @@ flag {
    }
}

flag {
    name: "status_bar_app_handle_tracking"
    namespace: "systemui"
    description: "Have status bar track the location of app handles so that tappable status bar "
        "content doesn't overlap"
    bug: "397506207"
    metadata {
      purpose: PURPOSE_BUGFIX
    }
}

flag {
    name: "icon_refresh_2025"
    namespace: "systemui"
+274 −0
Original line number 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.layout.ui.viewmodel

import android.content.testableContext
import android.graphics.Rect
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import android.view.View
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.concurrency.fakeExecutor
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.backgroundScope
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.layout.StatusBarAppHandleTracking
import com.android.systemui.statusbar.policy.Clock
import com.android.systemui.testKosmos
import com.android.wm.shell.windowdecor.viewholder.AppHandleIdentifier
import com.android.wm.shell.windowdecor.viewholder.AppHandleIdentifier.AppHandleWindowingMode.APP_HANDLE_WINDOWING_MODE_BUBBLE
import com.android.wm.shell.windowdecor.viewholder.AppHandleIdentifier.AppHandleWindowingMode.APP_HANDLE_WINDOWING_MODE_FULLSCREEN
import com.android.wm.shell.windowdecor.viewholder.AppHandleIdentifier.AppHandleWindowingMode.APP_HANDLE_WINDOWING_MODE_SPLIT_SCREEN
import com.google.common.truth.Truth.assertThat
import java.util.Optional
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.doAnswer
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify

@SmallTest
@RunWith(AndroidJUnit4::class)
class StatusBarBoundsViewModelTest : SysuiTestCase() {
    private var clockBounds = Rect()
    private var startContainerBounds = Rect()

    private val kosmos =
        testKosmos().useUnconfinedTestDispatcher().apply {
            mockStatusBarClockView =
                mock<Clock> {
                    on { getBoundsOnScreen(any()) } doAnswer
                        {
                            val boundsOutput = it.arguments[0] as Rect
                            boundsOutput.set(clockBounds)
                            return@doAnswer
                        }
                }
            mockStatusBarStartSideContainerView =
                mock<View> {
                    on { getBoundsOnScreen(any()) } doAnswer
                        {
                            val boundsOutput = it.arguments[0] as Rect
                            boundsOutput.set(startContainerBounds)
                            return@doAnswer
                        }
                }
        }

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

    private val clockLayoutChangeListener: View.OnLayoutChangeListener
        get() {
            val captor = argumentCaptor<View.OnLayoutChangeListener>()
            verify(kosmos.mockStatusBarClockView).addOnLayoutChangeListener(captor.capture())
            return captor.firstValue
        }

    private val startContainerLayoutChangeListener: View.OnLayoutChangeListener
        get() {
            val captor = argumentCaptor<View.OnLayoutChangeListener>()
            verify(kosmos.mockStatusBarStartSideContainerView)
                .addOnLayoutChangeListener(captor.capture())
            return captor.firstValue
        }

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

    @Test
    fun startSideContainerBounds_getsUpdatedBounds() =
        kosmos.runTest {
            val firstRect = Rect(1, 2, 3, 4)
            startContainerBounds = firstRect
            startContainerLayoutChangeListener.onLayoutChange(
                mockStatusBarStartSideContainerView,
                0,
                0,
                0,
                0,
                0,
                0,
                0,
                0,
            )

            assertThat(underTest.startSideContainerBounds).isEqualTo(firstRect)

            val newRect = Rect(5, 6, 7, 8)
            startContainerBounds = newRect
            startContainerLayoutChangeListener.onLayoutChange(
                mockStatusBarStartSideContainerView,
                0,
                0,
                0,
                0,
                0,
                0,
                0,
                0,
            )

            assertThat(underTest.startSideContainerBounds).isEqualTo(newRect)
        }

    @Test
    fun clockBounds_getsUpdatedBounds() =
        kosmos.runTest {
            val firstRect = Rect(1, 2, 3, 4)
            clockBounds = firstRect
            clockLayoutChangeListener.onLayoutChange(mockStatusBarClockView, 0, 0, 0, 0, 0, 0, 0, 0)

            assertThat(underTest.clockBounds).isEqualTo(firstRect)

            val newRect = Rect(5, 6, 7, 8)
            clockBounds = newRect
            clockLayoutChangeListener.onLayoutChange(mockStatusBarClockView, 0, 0, 0, 0, 0, 0, 0, 0)

            assertThat(underTest.clockBounds).isEqualTo(newRect)
        }

    @Test
    @EnableFlags(StatusBarAppHandleTracking.FLAG_NAME)
    fun appHandleBounds_noAppHandlesProvided_empty() =
        kosmos.runTest {
            val viewModelWithNoAppHandles =
                StatusBarBoundsViewModel(
                    backgroundScope = backgroundScope,
                    sysuiMainExecutor = fakeExecutor,
                    appHandles = Optional.empty(),
                    thisDisplayId = testableContext.displayId,
                    startSideContainerView = mockStatusBarStartSideContainerView,
                    clockView = mockStatusBarClockView,
                )

            assertThat(viewModelWithNoAppHandles.appHandleBounds).isEmpty()
        }

    @Test
    @EnableFlags(StatusBarAppHandleTracking.FLAG_NAME)
    fun appHandleBounds_empty() =
        kosmos.runTest {
            fakeAppHandles.setAppHandles(emptyMap())

            assertThat(underTest.appHandleBounds).isEmpty()
        }

    @Test
    @EnableFlags(StatusBarAppHandleTracking.FLAG_NAME)
    fun appHandleBounds_notForThisDisplay_empty() =
        kosmos.runTest {
            val taskId = 10
            val rect = Rect(1, 2, 3, 4)
            fakeAppHandles.setAppHandles(
                mapOf(
                    taskId to
                        AppHandleIdentifier(
                            rect = rect,
                            displayId = testableContext.displayId + 2,
                            taskId = taskId,
                            windowingMode = APP_HANDLE_WINDOWING_MODE_FULLSCREEN,
                        )
                )
            )

            assertThat(underTest.appHandleBounds).isEmpty()
        }

    @Test
    @EnableFlags(StatusBarAppHandleTracking.FLAG_NAME)
    fun appHandleBounds_forThisDisplay_hasBounds() =
        kosmos.runTest {
            val taskId = 10
            val rect = Rect(1, 2, 3, 4)
            fakeAppHandles.setAppHandles(
                mapOf(
                    taskId to
                        AppHandleIdentifier(
                            rect = rect,
                            displayId = testableContext.displayId,
                            taskId = taskId,
                            windowingMode = APP_HANDLE_WINDOWING_MODE_FULLSCREEN,
                        )
                )
            )

            assertThat(underTest.appHandleBounds).containsExactly(rect)
        }

    @Test
    @EnableFlags(StatusBarAppHandleTracking.FLAG_NAME)
    fun appHandleBounds_multipleForThisDisplay_hasAll() =
        kosmos.runTest {
            val taskId1 = 10
            val rect1 = Rect(1, 2, 3, 4)

            val taskId2 = 20
            val rect2 = Rect(10, 20, 30, 40)

            fakeAppHandles.setAppHandles(
                mapOf(
                    taskId1 to
                        AppHandleIdentifier(
                            rect = rect1,
                            displayId = testableContext.displayId,
                            taskId = taskId1,
                            windowingMode = APP_HANDLE_WINDOWING_MODE_BUBBLE,
                        ),
                    taskId2 to
                        AppHandleIdentifier(
                            rect = rect2,
                            displayId = testableContext.displayId,
                            taskId = taskId2,
                            windowingMode = APP_HANDLE_WINDOWING_MODE_SPLIT_SCREEN,
                        ),
                )
            )

            assertThat(underTest.appHandleBounds).containsExactly(rect1, rect2)
        }

    @Test
    @DisableFlags(StatusBarAppHandleTracking.FLAG_NAME)
    fun appHandleBounds_emptyIfFlagDisabled() =
        kosmos.runTest {
            val taskId = 10
            val rect = Rect(1, 2, 3, 4)
            fakeAppHandles.setAppHandles(
                mapOf(
                    taskId to
                        AppHandleIdentifier(
                            rect = rect,
                            displayId = testableContext.displayId,
                            taskId = taskId,
                            windowingMode = APP_HANDLE_WINDOWING_MODE_FULLSCREEN,
                        )
                )
            )

            assertThat(underTest.appHandleBounds).isEmpty()
        }
}
+72 −0
Original line number 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.layout

import com.android.systemui.Flags
import com.android.systemui.flags.FlagToken
import com.android.systemui.flags.RefactorFlagUtils

/**
 * Helper for reading or using the status bar app handle tracking flag state.
 *
 * TODO(b/397506207): Make this flag dependent on
 *   com.android.window.flags.enable_app_handle_position_reporting.
 */
@Suppress("NOTHING_TO_INLINE")
object StatusBarAppHandleTracking {
    /** The aconfig flag name */
    const val FLAG_NAME = Flags.FLAG_STATUS_BAR_APP_HANDLE_TRACKING

    /** A token used for dependency declaration */
    val token: FlagToken
        get() = FlagToken(FLAG_NAME, isEnabled)

    /** Is the refactor enabled */
    @JvmStatic
    inline val isEnabled
        get() = Flags.statusBarAppHandleTracking()

    /**
     * Called to ensure code is only run when the flag is enabled. This can be used to protect users
     * from the unintended behaviors caused by accidentally running new logic, while also crashing
     * on an eng build to ensure that the refactor author catches issues in testing.
     */
    @JvmStatic
    inline fun isUnexpectedlyInLegacyMode() =
        RefactorFlagUtils.isUnexpectedlyInLegacyMode(isEnabled, FLAG_NAME)

    /**
     * Called to ensure code is only run when the flag is enabled. This will call Log.wtf if the
     * flag is not enabled to ensure that the refactor author catches issues in testing.
     *
     * NOTE: This can be useful for simple methods, but does not return the flag state, so it cannot
     * be used to implement a safe exit, and as such it does not support code stripping. If the
     * calling code will do work that is unsafe when the flag is off, it is recommended to write an
     * early return with `if (isUnexpectedlyInLegacyMode()) return`.
     */
    @JvmStatic
    inline fun expectInNewMode() {
        RefactorFlagUtils.isUnexpectedlyInLegacyMode(isEnabled, FLAG_NAME)
    }

    /**
     * Called to ensure code is only run when the flag is disabled. This will throw an exception if
     * the flag is enabled to ensure that the refactor author catches issues in testing.
     */
    @JvmStatic
    inline fun assertInLegacyMode() = RefactorFlagUtils.assertInLegacyMode(isEnabled, FLAG_NAME)
}
+142 −0
Original line number 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.layout.ui.viewmodel

import android.graphics.Rect
import android.view.View
import androidx.compose.runtime.getValue
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.statusbar.layout.StatusBarAppHandleTracking
import com.android.systemui.statusbar.policy.Clock
import com.android.systemui.util.boundsOnScreen
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import com.android.wm.shell.windowdecor.viewholder.AppHandlePositionCallback
import com.android.wm.shell.windowdecor.viewholder.AppHandles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import java.util.Optional
import java.util.concurrent.Executor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn

/**
 * View model for on-screen bounds of elements related to the status bar.
 *
 * Recommended Architecture variant of [StatusBarBoundsProvider].
 */
class StatusBarBoundsViewModel
@AssistedInject
constructor(
    @Assisted thisDisplayId: Int,
    @Assisted private val startSideContainerView: View,
    @Assisted private val clockView: Clock,
    appHandles: Optional<AppHandles>,
    @Background backgroundScope: CoroutineScope,
    @Main sysuiMainExecutor: Executor,
) : ExclusiveActivatable() {
    private val hydrator = Hydrator(traceName = "StatusBarBoundsViewModel.hydrator")

    private val _startSideContainerBounds: Flow<Rect> =
        conflatedCallbackFlow {
                val layoutListener =
                    View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
                        trySend(startSideContainerView.boundsOnScreen)
                    }
                startSideContainerView.addOnLayoutChangeListener(layoutListener)
                awaitClose { startSideContainerView.removeOnLayoutChangeListener(layoutListener) }
            }
            .stateIn(backgroundScope, SharingStarted.WhileSubscribed(), initialValue = Rect())

    /**
     * The on-screen bounds of the start side container of the status bar, which always fills the
     * available start-side space. This is a hydrated value.
     */
    val startSideContainerBounds: Rect by
        hydrator.hydratedStateOf(
            traceName = "StatusBar.startSideContainerBounds",
            initialValue = Rect(),
            source = _startSideContainerBounds,
        )

    private val _clockBounds: Flow<Rect> =
        conflatedCallbackFlow {
                val layoutListener =
                    View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
                        trySend(clockView.boundsOnScreen)
                    }
                clockView.addOnLayoutChangeListener(layoutListener)
                awaitClose { clockView.removeOnLayoutChangeListener(layoutListener) }
            }
            .stateIn(backgroundScope, SharingStarted.WhileSubscribed(), initialValue = Rect())

    /** The on-screen bounds of the status bar clock. This is a hydrated value. */
    // TODO(b/390204943): Re-implement this in Compose once the Clock is a Composable.
    val clockBounds: Rect by
        hydrator.hydratedStateOf(
            traceName = "StatusBar.clockBounds",
            initialValue = Rect(),
            source = _clockBounds,
        )

    private val _appHandleBounds: Flow<List<Rect>> =
        if (StatusBarAppHandleTracking.isEnabled && appHandles.isPresent) {
                conflatedCallbackFlow {
                    val listener = AppHandlePositionCallback { handles ->
                        trySend(
                            handles.values.filter { it.displayId == thisDisplayId }.map { it.rect }
                        )
                    }
                    appHandles.get().addListener(sysuiMainExecutor, listener)
                    awaitClose { appHandles.get().removeListener(listener) }
                }
            } else {
                flowOf(emptyList())
            }
            .stateIn(backgroundScope, SharingStarted.WhileSubscribed(), emptyList())

    /**
     * The on-screen bounds where app handles are showing. Used so that we can ensure clickable
     * status bar content doesn't overlap with them. This is a hydrated value.
     */
    val appHandleBounds: List<Rect> by
        hydrator.hydratedStateOf(
            traceName = "StatusBar.appHandleBounds",
            initialValue = emptyList(),
            source = _appHandleBounds,
        )

    override suspend fun onActivated(): Nothing {
        hydrator.activate()
    }

    @AssistedFactory
    interface Factory {
        fun create(
            displayId: Int,
            startSideContainerView: View,
            clockView: Clock,
        ): StatusBarBoundsViewModel
    }
}
+41 −0
Original line number 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.layout.ui.viewmodel

import com.android.wm.shell.windowdecor.viewholder.AppHandleIdentifier
import com.android.wm.shell.windowdecor.viewholder.AppHandlePositionCallback
import com.android.wm.shell.windowdecor.viewholder.AppHandles
import java.util.concurrent.Executor

class FakeAppHandles : AppHandles {
    private var currentAppHandles: Map<Int, AppHandleIdentifier> = emptyMap()

    val listeners = mutableSetOf<AppHandlePositionCallback>()

    override fun addListener(sysuiExecutor: Executor, listener: AppHandlePositionCallback) {
        listeners.add(listener)
    }

    override fun removeListener(listener: AppHandlePositionCallback) {
        listeners.remove(listener)
    }

    fun setAppHandles(handles: Map<Int, AppHandleIdentifier>) {
        currentAppHandles = handles
        listeners.forEach { it.onAppHandlesUpdated(handles) }
    }
}
 No newline at end of file
Loading