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

Commit a666e955 authored by Ahmed Mehfooz's avatar Ahmed Mehfooz Committed by Android (Google) Code Review
Browse files

Merge changes I23538ba8,I5a814fb0 into main

* changes:
  [SB][ComposeIcons] Add profile icon to status bar
  [SB][ComposeIcons] Add ManagedProfileInteractor
parents 67fa1259 33cf6729
Loading
Loading
Loading
Loading
+164 −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.policy.profile.domain.interactor

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.ObservableTransitionState
import com.android.compose.animation.scene.SceneKey
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.data.repository.fakeDeviceEntryRepository
import com.android.systemui.keyguard.data.repository.fakeKeyguardTransitionRepository
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.collectLastValue
import com.android.systemui.kosmos.runTest
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.domain.startable.sceneContainerStartable
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.statusbar.policy.profile.data.repository.managedProfileRepository
import com.android.systemui.statusbar.policy.profile.shared.model.ProfileInfo
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
@ExperimentalCoroutinesApi
@android.platform.test.annotations.EnabledOnRavenwood
class ManagedProfileInteractorTest : SysuiTestCase() {

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val underTest = kosmos.managedProfileInteractor

    @Before
    fun setUp() {
        kosmos.sceneContainerStartable.start()
        kosmos.managedProfileRepository.currentProfileInfo.value = profileInfo
    }

    @Test
    fun currentProfileInfo_whenDeviceIsEntered_isShown() =
        kosmos.runTest {
            val info by collectLastValue(underTest.currentProfileInfo)

            // GIVEN device is locked
            setDeviceAsLocked()
            assertThat(info).isNull()

            // WHEN device is entered (unlocked)
            setDeviceAsEntered()

            // THEN profile info is shown
            assertThat(info).isEqualTo(profileInfo)
        }

    @Test
    fun currentProfileInfo_whenDeviceBecomesLocked_isHidden() =
        kosmos.runTest {
            val info by collectLastValue(underTest.currentProfileInfo)

            // GIVEN device is unlocked
            setDeviceAsEntered()
            assertThat(info).isEqualTo(profileInfo)

            // WHEN device becomes locked
            setDeviceAsLocked()

            // THEN profile info is hidden
            assertThat(info).isNull()
        }

    @Test
    fun currentProfileInfo_whenKeyguardIsOccluded_isShown() =
        kosmos.runTest {
            val info by collectLastValue(underTest.currentProfileInfo)

            // GIVEN device is locked and not occluded
            setDeviceAsLocked()
            assertThat(info).isNull()

            // WHEN keyguard becomes occluded
            setKeyguardState(from = KeyguardState.LOCKSCREEN, to = KeyguardState.OCCLUDED)

            // THEN profile info is shown
            assertThat(info).isEqualTo(profileInfo)
        }

    @Test
    fun currentProfileInfo_whenKeyguardBecomesNotOccluded_isHidden() =
        kosmos.runTest {
            val info by collectLastValue(underTest.currentProfileInfo)

            // GIVEN device is locked and occluded
            setDeviceAsLocked()
            setKeyguardState(from = KeyguardState.LOCKSCREEN, to = KeyguardState.OCCLUDED)
            assertThat(info).isEqualTo(profileInfo)

            // WHEN keyguard is no longer occluded
            setKeyguardState(from = KeyguardState.OCCLUDED, to = KeyguardState.LOCKSCREEN)

            // THEN profile info is hidden
            assertThat(info).isNull()
        }

    private fun Kosmos.setDeviceAsEntered() {
        fakeAuthenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.None)
        fakeDeviceEntryRepository.setLockscreenEnabled(false)
        setCurrentScene(Scenes.Gone)
    }

    private fun Kosmos.setDeviceAsLocked() {
        fakeAuthenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.Password)
        fakeDeviceEntryRepository.setLockscreenEnabled(true)
        setCurrentScene(Scenes.Lockscreen)
    }

    private fun Kosmos.setCurrentScene(scene: SceneKey) {
        sceneInteractor.changeScene(scene, "ManagedProfileInteractorTest")
        sceneInteractor.setTransitionState(flowOf(ObservableTransitionState.Idle(scene)))
    }

    private suspend fun Kosmos.setKeyguardState(from: KeyguardState, to: KeyguardState) {
        fakeKeyguardTransitionRepository.sendTransitionSteps(
            from = from,
            to = to,
            testScope = kosmos.testScope,
        )
    }

    companion object {
        private const val TEST_USER_ID = 10
        private const val TEST_ICON_RES_ID = 12345
        private const val TEST_ACCESSIBILITY_STRING = "Work apps"

        val profileInfo =
            ProfileInfo(
                userId = TEST_USER_ID,
                iconResId = TEST_ICON_RES_ID,
                contentDescription = TEST_ACCESSIBILITY_STRING,
            )
    }
}
+134 −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.systemstatusicons.profile.ui.viewmodel

import android.content.testableContext
import android.platform.test.annotations.EnableFlags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.ObservableTransitionState
import com.android.compose.animation.scene.SceneKey
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.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.deviceentry.data.repository.fakeDeviceEntryRepository
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.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.domain.startable.sceneContainerStartable
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.statusbar.policy.profile.data.repository.managedProfileRepository
import com.android.systemui.statusbar.policy.profile.shared.model.ProfileInfo
import com.android.systemui.statusbar.systemstatusicons.SystemStatusIconsInCompose
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.flowOf
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
@EnableFlags(SystemStatusIconsInCompose.FLAG_NAME)
@android.platform.test.annotations.EnabledOnRavenwood
class ManagedProfileIconViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val Kosmos.underTest by
        Kosmos.Fixture {
            managedProfileIconViewModelFactory.create(kosmos.testableContext).apply {
                activateIn(kosmos.testScope)
            }
        }

    @Before
    fun setUp() {
        kosmos.sceneContainerStartable.start()
    }

    @Test
    fun icon_managedProfileNotActive_outputsNull() =
        kosmos.runTest { assertThat(underTest.icon).isNull() }

    @Test
    fun icon_managedProfileActive_outputsIcon() =
        kosmos.runTest {
            // GIVEN keyguard is unlocked
            setDeviceAsEntered()

            // WHEN managed profile becomes active
            managedProfileRepository.currentProfileInfo.value = testProfileInfo

            // THEN the correct icon is output
            assertThat(underTest.icon).isEqualTo(expectedManagedProfileIcon)
        }

    @Test
    fun icon_updatesWhenManagedProfileStatusChanges() =
        kosmos.runTest {
            // GIVEN keyguard is unlocked
            setDeviceAsEntered()
            assertThat(underTest.icon).isNull()

            // WHEN managed profile becomes active
            managedProfileRepository.currentProfileInfo.value = testProfileInfo

            // THEN the icon is visible
            assertThat(underTest.icon).isEqualTo(expectedManagedProfileIcon)

            // WHEN managed profile becomes inactive
            managedProfileRepository.currentProfileInfo.value = null

            // THEN the icon is hidden
            assertThat(underTest.icon).isNull()
        }

    private fun Kosmos.setDeviceAsEntered() {
        fakeAuthenticationRepository.setAuthenticationMethod(AuthenticationMethodModel.None)
        fakeDeviceEntryRepository.setLockscreenEnabled(false)
        setCurrentScene(Scenes.Gone)
    }

    private fun Kosmos.setCurrentScene(scene: SceneKey) {
        sceneInteractor.changeScene(scene, "test")
        sceneInteractor.setTransitionState(flowOf(ObservableTransitionState.Idle(scene)))
    }

    companion object {
        private const val TEST_USER_ID = 10
        private const val TEST_ICON_RES_ID = 12345
        private const val TEST_ACCESSIBILITY_STRING = "Work profile"

        private val testProfileInfo =
            ProfileInfo(
                userId = TEST_USER_ID,
                iconResId = TEST_ICON_RES_ID,
                contentDescription = TEST_ACCESSIBILITY_STRING,
            )

        private val expectedManagedProfileIcon =
            Icon.Resource(
                res = TEST_ICON_RES_ID,
                contentDescription = ContentDescription.Loaded(TEST_ACCESSIBILITY_STRING),
            )
    }
}
+30 −13
Original line number Diff line number Diff line
@@ -22,21 +22,26 @@ import android.bluetooth.BluetoothProfile
import android.content.testableContext
import android.media.AudioManager
import android.platform.test.annotations.EnableFlags
import android.view.Display
import android.view.Display.TYPE_EXTERNAL
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.settingslib.bluetooth.CachedBluetoothDevice
import com.android.settingslib.notification.modes.TestModeBuilder
import com.android.settingslib.volume.shared.model.RingerMode
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.data.repository.fakeDeviceEntryRepository
import com.android.systemui.display.data.repository.display
import com.android.systemui.display.data.repository.displayRepository
import com.android.systemui.keyguard.data.repository.fakeKeyguardRepository
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.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.domain.startable.sceneContainerStartable
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.statusbar.core.NewStatusBarIcons
import com.android.systemui.statusbar.pipeline.airplane.data.repository.airplaneModeRepository
import com.android.systemui.statusbar.pipeline.shared.data.repository.connectivityRepository
@@ -48,6 +53,8 @@ import com.android.systemui.statusbar.policy.data.repository.fakeZenModeReposito
import com.android.systemui.statusbar.policy.fakeDataSaverController
import com.android.systemui.statusbar.policy.fakeHotspotController
import com.android.systemui.statusbar.policy.fakeNextAlarmController
import com.android.systemui.statusbar.policy.profile.data.repository.managedProfileRepository
import com.android.systemui.statusbar.policy.profile.shared.model.ProfileInfo
import com.android.systemui.statusbar.policy.vpn.data.repository.vpnRepository
import com.android.systemui.statusbar.policy.vpn.shared.model.VpnState
import com.android.systemui.statusbar.systemstatusicons.SystemStatusIconsInCompose
@@ -80,6 +87,7 @@ class SystemStatusIconsViewModelTest : SysuiTestCase() {
    private lateinit var slotDataSaver: String
    private lateinit var slotEthernet: String
    private lateinit var slotHotspot: String
    private lateinit var slotManagedProfile: String
    private lateinit var slotMute: String
    private lateinit var slotNextAlarm: String
    private lateinit var slotVibrate: String
@@ -96,6 +104,8 @@ class SystemStatusIconsViewModelTest : SysuiTestCase() {
        slotDataSaver = context.getString(com.android.internal.R.string.status_bar_data_saver)
        slotEthernet = context.getString(com.android.internal.R.string.status_bar_ethernet)
        slotHotspot = context.getString(com.android.internal.R.string.status_bar_hotspot)
        slotManagedProfile =
            context.getString(com.android.internal.R.string.status_bar_managed_profile)
        slotMute = context.getString(com.android.internal.R.string.status_bar_mute)
        slotNextAlarm = context.getString(com.android.internal.R.string.status_bar_alarm_clock)
        slotVibrate = context.getString(com.android.internal.R.string.status_bar_volume)
@@ -193,6 +203,14 @@ class SystemStatusIconsViewModelTest : SysuiTestCase() {
        kosmos.runTest {
            statusBarConfigIconSlotNames = emptyArray()

            // GIVEN the device is entered (unlocked). This is required for some icons to show.
            kosmos.sceneContainerStartable.start()
            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                AuthenticationMethodModel.None
            )
            kosmos.fakeDeviceEntryRepository.setLockscreenEnabled(false)
            kosmos.sceneInteractor.changeScene(Scenes.Gone, "SystemStatusIconsViewModelTest")

            showZenMode()
            showBluetooth()
            showConnectedDisplay()
@@ -203,6 +221,7 @@ class SystemStatusIconsViewModelTest : SysuiTestCase() {
            showVibrate()
            showHotspot()
            showVpn()
            showManagedProfile()

            assertThat(underTest.activeSlotNames)
                .containsExactly(
@@ -212,6 +231,7 @@ class SystemStatusIconsViewModelTest : SysuiTestCase() {
                    slotDataSaver,
                    slotEthernet,
                    slotHotspot,
                    slotManagedProfile,
                    slotNextAlarm,
                    slotVibrate,
                    slotVpn,
@@ -231,6 +251,7 @@ class SystemStatusIconsViewModelTest : SysuiTestCase() {
                    slotConnectedDisplay,
                    slotDataSaver,
                    slotHotspot,
                    slotManagedProfile,
                    slotMute,
                    slotNextAlarm,
                    slotVpn,
@@ -262,17 +283,8 @@ class SystemStatusIconsViewModelTest : SysuiTestCase() {
        )
    }

    private suspend fun Kosmos.showConnectedDisplay(isSecure: Boolean = false) {
        fakeKeyguardRepository.setKeyguardShowing(!isSecure)
        displayRepository.setDefaultDisplayOff(false)
        val flags = if (isSecure) Display.FLAG_SECURE else 0
        displayRepository.addDisplay(
            display(
                type = Display.TYPE_EXTERNAL,
                flags = flags,
                id = (displayRepository.displays.value.maxOfOrNull { it.displayId } ?: 0) + 1,
            )
        )
    private suspend fun Kosmos.showConnectedDisplay() {
        displayRepository.addDisplay(display(type = TYPE_EXTERNAL, id = 1))
    }

    private fun Kosmos.showEthernet() {
@@ -325,4 +337,9 @@ class SystemStatusIconsViewModelTest : SysuiTestCase() {
    private fun Kosmos.showVpn() {
        vpnRepository.vpnState.value = VpnState(isEnabled = true)
    }

    private fun Kosmos.showManagedProfile() {
        managedProfileRepository.currentProfileInfo.value =
            ProfileInfo(userId = 10, iconResId = 12345, contentDescription = "Work profile")
    }
}
+54 −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.policy.profile.domain.interactor

import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.deviceentry.domain.interactor.DeviceEntryInteractor
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.statusbar.policy.profile.data.repository.ManagedProfileRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine

/** Interactor to manage and provide an observable state of managed profile status. */
@SysUISingleton
class ManagedProfileInteractor
@Inject
constructor(
    repository: ManagedProfileRepository,
    keyguardTransitionInteractor: KeyguardTransitionInteractor,
    deviceEntryInteractor: DeviceEntryInteractor,
) {
    private val shouldShowProfileInfo: Flow<Boolean> =
        combine(
            deviceEntryInteractor.isDeviceEntered,
            keyguardTransitionInteractor.currentKeyguardState,
        ) { deviceEntered, keyguardState ->
            deviceEntered || keyguardState == KeyguardState.OCCLUDED
        }

    /** Flow that emits the current profile info, or null if it should be hidden. */
    val currentProfileInfo =
        combine(repository.currentProfileInfo, shouldShowProfileInfo) { profileInfo, shouldShow ->
            if (shouldShow) {
                profileInfo
            } else {
                null
            }
        }
}
+84 −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.systemstatusicons.profile.ui.viewmodel

import android.content.Context
import androidx.compose.runtime.getValue
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.statusbar.policy.profile.domain.interactor.ManagedProfileInteractor
import com.android.systemui.statusbar.policy.profile.shared.model.ProfileInfo
import com.android.systemui.statusbar.systemstatusicons.SystemStatusIconsInCompose
import com.android.systemui.statusbar.systemstatusicons.ui.viewmodel.SystemStatusIconViewModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject

/**
 * View model for the managed profile system status icon. Emits a managed profile icon when a
 * managed profile is active, null otherwise.
 */
class ManagedProfileIconViewModel
@AssistedInject
constructor(@Assisted private val context: Context, interactor: ManagedProfileInteractor) :
    SystemStatusIconViewModel.Default, ExclusiveActivatable() {

    init {
        SystemStatusIconsInCompose.expectInNewMode()
    }

    private val hydrator = Hydrator("ManagedProfileIconViewModel.hydrator")

    override val slotName: String =
        context.getString(com.android.internal.R.string.status_bar_managed_profile)

    private val profileInfo by
        hydrator.hydratedStateOf(
            traceName = null,
            initialValue = null,
            source = interactor.currentProfileInfo,
        )

    override val visible: Boolean
        get() = profileInfo != null

    override val icon: Icon?
        get() = profileInfo?.toIcon()

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

    private fun ProfileInfo.toIcon(): Icon {
        val contentDescriptionString =
            contentDescription.takeUnless { it.isNullOrBlank() }
                ?: context.resources.getString(
                    com.android.systemui.res.R.string.accessibility_managed_profile
                )

        return Icon.Resource(
            res = iconResId,
            contentDescription = ContentDescription.Loaded(contentDescriptionString),
        )
    }

    @AssistedFactory
    interface Factory {
        fun create(context: Context): ManagedProfileIconViewModel
    }
}
Loading