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

Commit 9ae344c1 authored by Ale Nijamkin's avatar Ale Nijamkin Committed by Android (Google) Code Review
Browse files

Merge "[flexiglass] Adds bouncer appear/disappear CUI markers." into main

parents af8ab8c8 f5c5e445
Loading
Loading
Loading
Loading
+32 −7
Original line number Diff line number Diff line
@@ -33,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.compose.animation.scene.ContentKey
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
@@ -43,11 +44,13 @@ import com.android.compose.animation.scene.SceneTransitions
import com.android.compose.animation.scene.UserAction
import com.android.compose.animation.scene.UserActionResult
import com.android.compose.animation.scene.observableTransitionState
import com.android.systemui.lifecycle.rememberActivated
import com.android.systemui.qs.ui.adapter.QSSceneAdapter
import com.android.systemui.qs.ui.composable.QuickSettingsTheme
import com.android.systemui.ribbon.ui.composable.BottomRightCornerRibbon
import com.android.systemui.scene.shared.model.SceneDataSourceDelegator
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.scene.ui.view.SceneJankMonitor
import com.android.systemui.scene.ui.viewmodel.SceneContainerViewModel
import javax.inject.Provider

@@ -82,14 +85,36 @@ fun SceneContainer(
    sceneTransitions: SceneTransitions,
    dataSourceDelegator: SceneDataSourceDelegator,
    qsSceneAdapter: Provider<QSSceneAdapter>,
    sceneJankMonitorFactory: SceneJankMonitor.Factory,
    modifier: Modifier = Modifier,
) {
    val coroutineScope = rememberCoroutineScope()
    val state: MutableSceneTransitionLayoutState = remember {

    val view = LocalView.current
    val sceneJankMonitor =
        rememberActivated(traceName = "sceneJankMonitor") { sceneJankMonitorFactory.create() }

    val state: MutableSceneTransitionLayoutState =
        remember(view, sceneJankMonitor) {
            MutableSceneTransitionLayoutState(
                initialScene = initialSceneKey,
                canChangeScene = { toScene -> viewModel.canChangeScene(toScene) },
                transitions = sceneTransitions,
                onTransitionStart = { transition ->
                    sceneJankMonitor.onTransitionStart(
                        view = view,
                        from = transition.fromContent,
                        to = transition.toContent,
                        cuj = transition.cuj,
                    )
                },
                onTransitionEnd = { transition ->
                    sceneJankMonitor.onTransitionEnd(
                        from = transition.fromContent,
                        to = transition.toContent,
                        cuj = transition.cuj,
                    )
                },
            )
        }

+2 −0
Original line number Diff line number Diff line
@@ -69,6 +69,7 @@ import com.android.systemui.scene.shared.model.sceneDataSourceDelegator
import com.android.systemui.scene.ui.composable.Scene
import com.android.systemui.scene.ui.composable.SceneContainer
import com.android.systemui.scene.ui.composable.SceneContainerTransitions
import com.android.systemui.scene.ui.view.sceneJankMonitorFactory
import com.android.systemui.testKosmos
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.awaitCancellation
@@ -193,6 +194,7 @@ class BouncerPredictiveBackTest : SysuiTestCase() {
                                overlayByKey = emptyMap(),
                                dataSourceDelegator = kosmos.sceneDataSourceDelegator,
                                qsSceneAdapter = { kosmos.fakeQsSceneAdapter },
                                sceneJankMonitorFactory = kosmos.sceneJankMonitorFactory,
                            )
                        }
                    },
+206 −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.scene.ui.view

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.jank.Cuj
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.deviceentry.domain.interactor.deviceUnlockedInteractor
import com.android.systemui.jank.interactionJankMonitor
import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintAuthRepository
import com.android.systemui.keyguard.shared.model.SuccessFingerprintAuthenticationStatus
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.runCurrent
import com.android.systemui.kosmos.runTestWithSnapshots
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.kotlin.any
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

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

    private val kosmos = testKosmos()
    private val underTest: SceneJankMonitor = kosmos.sceneJankMonitorFactory.create()

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

    @Test
    fun onTransitionStart_withProvidedCuj_beginsThatCuj() =
        kosmos.runTestWithSnapshots {
            val cuj = 1337
            underTest.onTransitionStart(
                view = mock(),
                from = Scenes.Communal,
                to = Scenes.Dream,
                cuj = cuj,
            )
            verify(interactionJankMonitor).begin(any(), eq(cuj))
            verify(interactionJankMonitor, never()).end(anyInt())
        }

    @Test
    fun onTransitionEnd_withProvidedCuj_endsThatCuj() =
        kosmos.runTestWithSnapshots {
            val cuj = 1337
            underTest.onTransitionEnd(from = Scenes.Communal, to = Scenes.Dream, cuj = cuj)
            verify(interactionJankMonitor, never()).begin(any(), anyInt())
            verify(interactionJankMonitor).end(cuj)
        }

    @Test
    fun bouncer_authMethodPin() =
        kosmos.runTestWithSnapshots {
            bouncer(
                authenticationMethod = AuthenticationMethodModel.Pin,
                appearCuj = Cuj.CUJ_LOCKSCREEN_PIN_APPEAR,
                disappearCuj = Cuj.CUJ_LOCKSCREEN_PIN_DISAPPEAR,
            )
        }

    @Test
    fun bouncer_authMethodSim() =
        kosmos.runTestWithSnapshots {
            bouncer(
                authenticationMethod = AuthenticationMethodModel.Sim,
                appearCuj = Cuj.CUJ_LOCKSCREEN_PIN_APPEAR,
                disappearCuj = Cuj.CUJ_LOCKSCREEN_PIN_DISAPPEAR,
                // When the auth method is SIM, unlocking doesn't work like normal. Instead of
                // leaving the bouncer, the bouncer is switched over to the real authentication
                // method when the SIM is unlocked.
                //
                // Therefore, there's no point in testing this code path and it will, in fact, fail
                // to unlock.
                testUnlockedDisappearance = false,
            )
        }

    @Test
    fun bouncer_authMethodPattern() =
        kosmos.runTestWithSnapshots {
            bouncer(
                authenticationMethod = AuthenticationMethodModel.Pattern,
                appearCuj = Cuj.CUJ_LOCKSCREEN_PATTERN_APPEAR,
                disappearCuj = Cuj.CUJ_LOCKSCREEN_PATTERN_DISAPPEAR,
            )
        }

    @Test
    fun bouncer_authMethodPassword() =
        kosmos.runTestWithSnapshots {
            bouncer(
                authenticationMethod = AuthenticationMethodModel.Password,
                appearCuj = Cuj.CUJ_LOCKSCREEN_PASSWORD_APPEAR,
                disappearCuj = Cuj.CUJ_LOCKSCREEN_PASSWORD_DISAPPEAR,
            )
        }

    private fun Kosmos.bouncer(
        authenticationMethod: AuthenticationMethodModel,
        appearCuj: Int,
        disappearCuj: Int,
        testUnlockedDisappearance: Boolean = true,
    ) {
        // Set up state:
        fakeAuthenticationRepository.setAuthenticationMethod(authenticationMethod)
        runCurrent()

        fun verifyCujCounts(
            beginAppearCount: Int = 0,
            beginDisappearCount: Int = 0,
            endAppearCount: Int = 0,
            endDisappearCount: Int = 0,
        ) {
            verify(interactionJankMonitor, times(beginAppearCount)).begin(any(), eq(appearCuj))
            verify(interactionJankMonitor, times(beginDisappearCount))
                .begin(any(), eq(disappearCuj))
            verify(interactionJankMonitor, times(endAppearCount)).end(appearCuj)
            verify(interactionJankMonitor, times(endDisappearCount)).end(disappearCuj)
        }

        // Precondition checks:
        assertThat(deviceUnlockedInteractor.deviceUnlockStatus.value.isUnlocked).isFalse()
        verifyCujCounts()

        // Bouncer appears CUJ:
        underTest.onTransitionStart(
            view = mock(),
            from = Scenes.Lockscreen,
            to = Scenes.Bouncer,
            cuj = null,
        )
        verifyCujCounts(beginAppearCount = 1)
        underTest.onTransitionEnd(from = Scenes.Lockscreen, to = Scenes.Bouncer, cuj = null)
        verifyCujCounts(beginAppearCount = 1, endAppearCount = 1)

        // Bouncer disappear CUJ but it doesn't log because the device isn't unlocked.
        underTest.onTransitionStart(
            view = mock(),
            from = Scenes.Bouncer,
            to = Scenes.Lockscreen,
            cuj = null,
        )
        verifyCujCounts(beginAppearCount = 1, endAppearCount = 1)
        underTest.onTransitionEnd(from = Scenes.Bouncer, to = Scenes.Lockscreen, cuj = null)
        verifyCujCounts(beginAppearCount = 1, endAppearCount = 1)

        if (!testUnlockedDisappearance) {
            return
        }

        // Unlock the device and transition away from the bouncer.
        fakeDeviceEntryFingerprintAuthRepository.setAuthenticationStatus(
            SuccessFingerprintAuthenticationStatus(0, true)
        )
        runCurrent()
        assertThat(deviceUnlockedInteractor.deviceUnlockStatus.value.isUnlocked).isTrue()

        // Bouncer disappear CUJ and it doeslog because the device is unlocked.
        underTest.onTransitionStart(
            view = mock(),
            from = Scenes.Bouncer,
            to = Scenes.Gone,
            cuj = null,
        )
        verifyCujCounts(beginAppearCount = 1, endAppearCount = 1, beginDisappearCount = 1)
        underTest.onTransitionEnd(from = Scenes.Bouncer, to = Scenes.Gone, cuj = null)
        verifyCujCounts(
            beginAppearCount = 1,
            endAppearCount = 1,
            beginDisappearCount = 1,
            endDisappearCount = 1,
        )
    }
}
+124 −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.scene.ui.view

import android.view.View
import androidx.compose.runtime.getValue
import com.android.compose.animation.scene.ContentKey
import com.android.internal.jank.Cuj
import com.android.internal.jank.Cuj.CujType
import com.android.internal.jank.InteractionJankMonitor
import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.deviceentry.domain.interactor.DeviceUnlockedInteractor
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.scene.shared.model.Scenes
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

/**
 * Monitors scene transitions and reports the beginning and ending of each scene-related CUJ.
 *
 * This general-purpose monitor can be expanded to include other rules that respond to the beginning
 * and/or ending of transitions and reports jank CUI markers to the [InteractionJankMonitor].
 */
class SceneJankMonitor
@AssistedInject
constructor(
    authenticationInteractor: AuthenticationInteractor,
    private val deviceUnlockedInteractor: DeviceUnlockedInteractor,
    private val interactionJankMonitor: InteractionJankMonitor,
) : ExclusiveActivatable() {

    private val hydrator = Hydrator("SceneJankMonitor.hydrator")
    private val authMethod: AuthenticationMethodModel? by
        hydrator.hydratedStateOf(
            traceName = "authMethod",
            initialValue = null,
            source = authenticationInteractor.authenticationMethod,
        )

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

    /**
     * Notifies that a transition is at its start.
     *
     * Should be called exactly once each time a new transition starts.
     */
    fun onTransitionStart(view: View, from: ContentKey, to: ContentKey, @CujType cuj: Int?) {
        cuj.orCalculated(from, to) { nonNullCuj -> interactionJankMonitor.begin(view, nonNullCuj) }
    }

    /**
     * Notifies that the previous transition is at its end.
     *
     * Should be called exactly once each time a transition ends.
     */
    fun onTransitionEnd(from: ContentKey, to: ContentKey, @CujType cuj: Int?) {
        cuj.orCalculated(from, to) { nonNullCuj -> interactionJankMonitor.end(nonNullCuj) }
    }

    /**
     * Returns this CUI marker (CUJ identifier), one that's calculated based on other state, or
     * `null`, if no appropriate CUJ could be calculated.
     */
    private fun Int?.orCalculated(
        from: ContentKey,
        to: ContentKey,
        ifNotNull: (nonNullCuj: Int) -> Unit,
    ) {
        val thisOrCalculatedCuj = this ?: calculatedCuj(from = from, to = to)

        if (thisOrCalculatedCuj != null) {
            ifNotNull(thisOrCalculatedCuj)
        }
    }

    @CujType
    private fun calculatedCuj(from: ContentKey, to: ContentKey): Int? {
        val isDeviceUnlocked = deviceUnlockedInteractor.deviceUnlockStatus.value.isUnlocked
        return when {
            to == Scenes.Bouncer ->
                when (authMethod) {
                    is AuthenticationMethodModel.Pin,
                    is AuthenticationMethodModel.Sim -> Cuj.CUJ_LOCKSCREEN_PIN_APPEAR
                    is AuthenticationMethodModel.Pattern -> Cuj.CUJ_LOCKSCREEN_PATTERN_APPEAR
                    is AuthenticationMethodModel.Password -> Cuj.CUJ_LOCKSCREEN_PASSWORD_APPEAR
                    is AuthenticationMethodModel.None -> null
                    null -> null
                }
            from == Scenes.Bouncer && isDeviceUnlocked ->
                when (authMethod) {
                    is AuthenticationMethodModel.Pin,
                    is AuthenticationMethodModel.Sim -> Cuj.CUJ_LOCKSCREEN_PIN_DISAPPEAR
                    is AuthenticationMethodModel.Pattern -> Cuj.CUJ_LOCKSCREEN_PATTERN_DISAPPEAR
                    is AuthenticationMethodModel.Password -> Cuj.CUJ_LOCKSCREEN_PASSWORD_DISAPPEAR
                    is AuthenticationMethodModel.None -> null
                    null -> null
                }
            else -> null
        }
    }

    @AssistedFactory
    interface Factory {
        fun create(): SceneJankMonitor
    }
}
+2 −0
Original line number Diff line number Diff line
@@ -34,6 +34,7 @@ class SceneWindowRootView(context: Context, attrs: AttributeSet?) : WindowRootVi
        layoutInsetController: LayoutInsetsController,
        sceneDataSourceDelegator: SceneDataSourceDelegator,
        qsSceneAdapter: Provider<QSSceneAdapter>,
        sceneJankMonitorFactory: SceneJankMonitor.Factory,
    ) {
        setLayoutInsetsController(layoutInsetController)
        SceneWindowRootViewBinder.bind(
@@ -52,6 +53,7 @@ class SceneWindowRootView(context: Context, attrs: AttributeSet?) : WindowRootVi
            },
            dataSourceDelegator = sceneDataSourceDelegator,
            qsSceneAdapter = qsSceneAdapter,
            sceneJankMonitorFactory = sceneJankMonitorFactory,
        )
    }

Loading