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

Commit 21bbe4a0 authored by amehfooz's avatar amehfooz
Browse files

[SB][ComposeIcons] Add ZenMode icon

Bug: 413531283
Test: Make sure zen mode icon is updated
Screenshot provided in bug.
Flag: com.android.systemui.status_bar_system_status_icons_in_compose

Change-Id: If1e941109a894e407d65305d62bfd195c1c7ba9b
parent 8cb331c7
Loading
Loading
Loading
Loading
+217 −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.zenmode.ui.viewmodel

import android.app.AutomaticZenRule
import android.graphics.drawable.TestStubDrawable
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.R
import com.android.settingslib.notification.modes.TestModeBuilder
import com.android.systemui.SysuiTestCase
import com.android.systemui.SysuiTestableContext
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
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.policy.data.repository.fakeZenModeRepository
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

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

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private var underTest: ZenModeIconViewModel =
        kosmos.zenModeIconViewModelFactory.create().apply { activateIn(kosmos.testScope) }

    @Before
    fun setUp() {
        val customPackageContext = SysuiTestableContext(context)
        context.prepareCreatePackageContext(CUSTOM_PACKAGE_NAME, customPackageContext)
        customPackageContext.orCreateTestableResources.apply {
            addOverride(CUSTOM_ICON_RES_ID, CUSTOM_DRAWABLE)
        }
    }

    @Test
    fun icon_noActiveMode_isNull() =
        kosmos.runTest {
            fakeZenModeRepository.clearModes()

            assertThat(underTest.icon).isNull()
        }

    @Test
    fun icon_oneActiveMode_showsCorrectIconAndDescription() =
        kosmos.runTest {
            fakeZenModeRepository.clearModes()
            val modeId = "test_mode_1"
            val modeName = "My Zen Mode"
            val mode =
                TestModeBuilder()
                    .setId(modeId)
                    .setName(modeName)
                    .setType(AutomaticZenRule.TYPE_DRIVING)
                    .setActive(false)
                    .build()
            fakeZenModeRepository.addMode(mode)
            fakeZenModeRepository.activateMode(modeId) // Activate it

            assertThat(underTest.icon).isInstanceOf(Icon.Loaded::class.java)

            val loadedIcon = underTest.icon as Icon.Loaded
            assertThat(loadedIcon.contentDescription).isEqualTo(ContentDescription.Loaded(modeName))
            assertThat(loadedIcon.res).isEqualTo(R.drawable.ic_zen_mode_type_driving)
        }

    @Test
    fun icon_multipleActiveModes_showsHighestPriorityIcon() =
        kosmos.runTest {
            fakeZenModeRepository.clearModes()
            val highPriModeId = "bedtime"
            val highPriModeName = "Bedtime"
            val lowPriModeId = "other"
            val lowPriModeName = "Other Zen"

            val highPriMode =
                TestModeBuilder()
                    .setId(highPriModeId)
                    .setName(highPriModeName)
                    .setType(AutomaticZenRule.TYPE_BEDTIME)
                    .setActive(false)
                    .build()
            val lowPriMode =
                TestModeBuilder()
                    .setId(lowPriModeId)
                    .setName(lowPriModeName)
                    .setType(AutomaticZenRule.TYPE_OTHER) // Lower priority type
                    .setPackage(context.packageName)
                    .setIconResId(R.drawable.ic_zen_mode_type_driving)
                    .setActive(false)
                    .build()

            fakeZenModeRepository.addModes(listOf(highPriMode, lowPriMode))
            // Activate both (order shouldn't matter for the final state)
            fakeZenModeRepository.activateMode(lowPriModeId)
            fakeZenModeRepository.activateMode(highPriModeId)

            // THEN the icon shown corresponds to the highest priority mode (Bedtime)
            val actualIcon = underTest.icon
            assertThat(actualIcon).isInstanceOf(Icon.Loaded::class.java)

            val loadedIcon = actualIcon as Icon.Loaded

            assertThat(loadedIcon.res).isEqualTo(R.drawable.ic_zen_mode_type_bedtime)
            assertThat(loadedIcon.contentDescription)
                .isEqualTo(ContentDescription.Loaded(highPriModeName))
        }

    @Test
    fun icon_updatesWhenActivationChanges() =
        kosmos.runTest {
            fakeZenModeRepository.clearModes()
            val modeId = "update_test"
            val modeName = "Dynamic Zen"
            val mode =
                TestModeBuilder()
                    .setId(modeId)
                    .setName(modeName)
                    .setType(AutomaticZenRule.TYPE_DRIVING)
                    .setActive(false)
                    .build()
            fakeZenModeRepository.addMode(mode)

            assertThat(underTest.icon).isNull()

            fakeZenModeRepository.activateMode(modeId)

            val actualIcon = underTest.icon
            assertThat(actualIcon).isInstanceOf(Icon.Loaded::class.java)
            val loadedIcon = actualIcon as Icon.Loaded
            assertThat(loadedIcon.res).isEqualTo(R.drawable.ic_zen_mode_type_driving)
            assertThat(loadedIcon.contentDescription).isEqualTo(ContentDescription.Loaded(modeName))

            fakeZenModeRepository.deactivateMode(modeId)
            assertThat(underTest.icon).isNull()
        }

    @Test
    fun icon_multipleActiveModes_updatesToNextPriorityWhenHigherDeactivated_customIcon() =
        kosmos.runTest {
            val highPriModeId = "high_pri_res"
            val highPriModeName = "High Priority Resource"

            val lowPriModeId = "low_pri_custom"
            val lowPriModeName = "Low Priority Custom"

            val highPriMode =
                TestModeBuilder()
                    .setId(highPriModeId)
                    .setName(highPriModeName)
                    .setType(AutomaticZenRule.TYPE_BEDTIME)
                    .setActive(false)
                    .build()
            val lowPriMode =
                TestModeBuilder()
                    .setId(lowPriModeId)
                    .setName(lowPriModeName)
                    .setType(AutomaticZenRule.TYPE_OTHER)
                    .setPackage(CUSTOM_PACKAGE_NAME)
                    .setIconResId(CUSTOM_ICON_RES_ID)
                    .setActive(false)
                    .build()

            kosmos.fakeZenModeRepository.addModes(listOf(highPriMode, lowPriMode))

            kosmos.fakeZenModeRepository.activateMode(lowPriModeId)
            kosmos.fakeZenModeRepository.activateMode(highPriModeId)

            var currentIcon = underTest.icon
            assertThat(currentIcon).isInstanceOf(Icon.Loaded::class.java)
            var loadedIcon = currentIcon as Icon.Loaded
            assertThat(loadedIcon.res).isEqualTo(R.drawable.ic_zen_mode_type_bedtime)
            assertThat(loadedIcon.contentDescription)
                .isEqualTo(ContentDescription.Loaded(highPriModeName))

            // Deactivate the high priority mode, then the low priority (custom) icon should be
            // shown
            kosmos.fakeZenModeRepository.deactivateMode(highPriModeId)

            currentIcon = underTest.icon
            assertThat(currentIcon).isInstanceOf(Icon.Loaded::class.java)
            loadedIcon = currentIcon as Icon.Loaded

            assertThat(loadedIcon.contentDescription)
                .isEqualTo(ContentDescription.Loaded(lowPriModeName))
            // Resource ID should be null, but the drawable should be present.
            assertThat(loadedIcon.res).isNull()
            assertThat(loadedIcon.drawable).isEqualTo(CUSTOM_DRAWABLE)
        }

    private companion object {
        const val CUSTOM_PACKAGE_NAME = "com.example.custom.zen.mode.provider"
        const val CUSTOM_ICON_RES_ID = 54321 // Arbitrary ID for the custom resource
        val CUSTOM_DRAWABLE = TestStubDrawable("custom_zen_icon")
    }
}
+1 −0
Original line number Diff line number Diff line
@@ -37,6 +37,7 @@ fun SystemStatusIcons(viewModelFactory: SystemStatusIconsViewModel.Factory, isDa

    for (icon in viewModel.icons) {
        // TODO(407622922): Use isDark to color the icon.
        // TODO(414653733): Make sure icons are sized uniformly.
        Icon(icon = icon, modifier = Modifier.size(20.dp).padding(1.dp), tint = Color.Red)
    }
}
+6 −2
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import com.android.systemui.statusbar.systemstatusicons.SystemStatusIconsInCompo
import com.android.systemui.statusbar.systemstatusicons.airplane.ui.viewmodel.AirplaneModeIconViewModel
import com.android.systemui.statusbar.systemstatusicons.bluetooth.ui.viewmodel.BluetoothIconViewModel
import com.android.systemui.statusbar.systemstatusicons.ethernet.ui.viewmodel.EthernetIconViewModel
import com.android.systemui.statusbar.systemstatusicons.zenmode.ui.viewmodel.ZenModeIconViewModel
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.awaitCancellation
@@ -40,17 +41,19 @@ constructor(
    airplaneModeIconViewModelFactory: AirplaneModeIconViewModel.Factory,
    bluetoothIconViewModelFactory: BluetoothIconViewModel.Factory,
    ethernetIconViewModelFactory: EthernetIconViewModel.Factory,
    zenModeIconViewModelFactory: ZenModeIconViewModel.Factory,
) : ExclusiveActivatable() {

    init {
        /* check if */ SystemStatusIconsInCompose.isUnexpectedlyInLegacyMode()
        SystemStatusIconsInCompose.expectInNewMode()
    }

    private val airplaneModeIcon by lazy { airplaneModeIconViewModelFactory.create() }
    private val ethernetIcon by lazy { ethernetIconViewModelFactory.create() }
    private val bluetoothIcon by lazy { bluetoothIconViewModelFactory.create() }
    private val zenModeIcon by lazy { zenModeIconViewModelFactory.create() }
    private val iconViewModels: List<SystemStatusIconViewModel> by lazy {
        listOf(bluetoothIcon, ethernetIcon, airplaneModeIcon)
        listOf(bluetoothIcon, zenModeIcon, ethernetIcon, airplaneModeIcon)
    }

    val icons: List<Icon>
@@ -58,6 +61,7 @@ constructor(

    override suspend fun onActivated(): Nothing {
        coroutineScope {
            launch { zenModeIcon.activate() }
            launch { ethernetIcon.activate() }
            launch { bluetoothIcon.activate() }
            launch { airplaneModeIcon.activate() }
+80 −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.zenmode.ui.viewmodel

import android.content.Context
import android.graphics.drawable.Drawable
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.dagger.qualifiers.Application
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.statusbar.policy.domain.interactor.ZenModeInteractor
import com.android.systemui.statusbar.policy.domain.model.ZenModeInfo
import com.android.systemui.statusbar.systemstatusicons.ui.viewmodel.SystemStatusIconViewModel
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.map

/**
 * ViewModel for the Zen Mode system status icon. Observes the current Zen mode state and provides
 * an appropriate [Icon] model for display.
 */
class ZenModeIconViewModel
@AssistedInject
constructor(interactor: ZenModeInteractor, @Application private val context: Context) :
    SystemStatusIconViewModel, ExclusiveActivatable() {

    private val hydrator: Hydrator = Hydrator("ZenModeIconViewModel.hydrator")

    override val icon: Icon? by
        hydrator.hydratedStateOf(
            traceName = "SystemStatus.zenModeIcon",
            initialValue = null,
            source = interactor.mainActiveMode.map { it?.toUiState() },
        )

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

    private fun ZenModeInfo.toUiState(): Icon.Loaded {
        // Make a copy of the drawable to ensure we can style it separately from the cached state.
        val cached: Drawable.ConstantState? = this.icon.drawable.constantState
        val drawable =
            cached?.newDrawable(context.resources)?.mutate() ?: this.icon.drawable.mutate()

        // ZenIconKey.resPackage is null if its resId is a system icon.
        val res =
            if (this.icon.key.resPackage == null) {
                this.icon.key.resId
            } else {
                null
            }
        return Icon.Loaded(
            drawable = drawable,
            contentDescription = ContentDescription.Loaded(this.name),
            res = res,
        )
    }

    @AssistedFactory
    interface Factory {
        fun create(): ZenModeIconViewModel
    }
}
+2 −0
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ import com.android.systemui.kosmos.Kosmos
import com.android.systemui.statusbar.systemstatusicons.airplane.ui.viewmodel.airplaneModeIconViewModelFactory
import com.android.systemui.statusbar.systemstatusicons.bluetooth.ui.viewmodel.bluetoothIconViewModelFactory
import com.android.systemui.statusbar.systemstatusicons.ethernet.ui.viewmodel.ethernetIconViewModelFactory
import com.android.systemui.statusbar.systemstatusicons.zenmode.ui.viewmodel.zenModeIconViewModelFactory

val Kosmos.systemStatusIconsViewModel by
    Kosmos.Fixture {
@@ -27,6 +28,7 @@ val Kosmos.systemStatusIconsViewModel by
            airplaneModeIconViewModelFactory = airplaneModeIconViewModelFactory,
            bluetoothIconViewModelFactory = bluetoothIconViewModelFactory,
            ethernetIconViewModelFactory = ethernetIconViewModelFactory,
            zenModeIconViewModelFactory = zenModeIconViewModelFactory,
        )
    }

Loading