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

Commit 02aea488 authored by Maryam Dehaini's avatar Maryam Dehaini
Browse files

Move app handle bounds flow to seperate view model

Creates a new view model to track app handle bounds reporting.

Bug: b/412444139
Test: manual testing + atest
Flag: com.android.systemui.status_bar_app_handle_tracking
Change-Id: Ib37f45857c718a61e96d09ea23862f985d7a0825
parent cab919af
Loading
Loading
Loading
Loading
+176 −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 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.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

@SmallTest
@RunWith(AndroidJUnit4::class)
class AppHandlesViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()

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

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

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

            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()
        }
}
+0 −132
Original line number Diff line number Diff line
@@ -16,30 +16,19 @@

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
@@ -150,125 +139,4 @@ class StatusBarBoundsViewModelTest : SysuiTestCase() {

            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()
        }
}
+7 −1
Original line number Diff line number Diff line
@@ -31,6 +31,7 @@ import com.android.systemui.statusbar.chips.ui.model.MultipleOngoingActivityChip
import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel
import com.android.systemui.statusbar.events.shared.model.SystemEventAnimationState.Idle
import com.android.systemui.statusbar.featurepods.popups.ui.model.PopupChipModel
import com.android.systemui.statusbar.layout.ui.viewmodel.AppHandlesViewModel
import com.android.systemui.statusbar.layout.ui.viewmodel.StatusBarBoundsViewModel
import com.android.systemui.statusbar.phone.domain.interactor.IsAreaDark
import com.android.systemui.statusbar.pipeline.battery.ui.viewmodel.BatteryNextToPercentViewModel
@@ -96,12 +97,17 @@ class FakeHomeStatusBarViewModel(
    override val statusBarBoundsViewModelFactory: StatusBarBoundsViewModel.Factory =
        object : StatusBarBoundsViewModel.Factory {
            override fun create(
                displayId: Int,
                startSideContainerView: View,
                clockView: Clock,
            ): StatusBarBoundsViewModel = mock(StatusBarBoundsViewModel::class.java)
        }

    override val appHandlesViewModelFactory: AppHandlesViewModel.Factory =
        object : AppHandlesViewModel.Factory {
            override fun create(displayId: Int): AppHandlesViewModel =
                mock(AppHandlesViewModel::class.java)
        }

    override val shouldShowOperatorNameView = MutableStateFlow(false)

    override val isClockVisible =
+87 −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 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.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 app handles overlapping with the status bar. */
class AppHandlesViewModel
@AssistedInject
constructor(
    @Assisted thisDisplayId: Int,
    appHandles: Optional<AppHandles>,
    @Background backgroundScope: CoroutineScope,
    @Main sysuiMainExecutor: Executor,
) : ExclusiveActivatable() {
    private val hydrator = Hydrator(traceName = "AppHandlesViewModel.hydrator")

    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): AppHandlesViewModel
    }
}
+1 −42
Original line number Diff line number Diff line
@@ -20,25 +20,18 @@ 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

/**
@@ -49,12 +42,9 @@ import kotlinx.coroutines.flow.stateIn
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")

@@ -100,43 +90,12 @@ constructor(
            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
        fun create(startSideContainerView: View, clockView: Clock): StatusBarBoundsViewModel
    }
}
Loading