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

Commit aa6e5170 authored by Evan Laird's avatar Evan Laird
Browse files

[battery] Compose-based battery icon

This CL adds the data/domain/ui level classes to support a
fully-composified battery icon, and also adds that icon

The icon itself is built in UnifiedBattery, and contains a few
conveniences that we will likely end up using in the future, such as a
pure, viewmodel-less version of the battery icon that is suitable for
the battery chip, a well as a view binder for use in an AndroidView.

Test: BatteryRepositoryTest
Test: BatteryInteractorTest
Test: BatteryViewModelTest
Test: manual
Bug: 314812750
Bug: 391605373
Flag: EXEMPT unused code as of yet

Change-Id: I894661c1374e1ba2cc91eb3f4b0673ddb191660a
parent 6459fc72
Loading
Loading
Loading
Loading
+3 −4
Original line number Diff line number Diff line
@@ -37,6 +37,7 @@ import com.android.systemui.lifecycle.activateIn
import com.android.systemui.plugins.activityStarter
import com.android.systemui.settings.fakeUserTracker
import com.android.systemui.statusbar.policy.batteryController
import com.android.systemui.statusbar.policy.fake
import com.android.systemui.testKosmos
import com.android.systemui.user.data.repository.fakeUserRepository
import com.android.systemui.util.settings.fakeSettings
@@ -47,7 +48,6 @@ import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mockito.verify
import org.mockito.kotlin.any
import org.mockito.kotlin.whenever

@SmallTest
@EnableFlags(FLAG_GLANCEABLE_HUB_V2)
@@ -69,7 +69,7 @@ class CommunalToDreamButtonViewModelTest : SysuiTestCase() {
    fun shouldShowDreamButtonOnHub_trueWhenPluggedIn() =
        with(kosmos) {
            runTest {
                whenever(batteryController.isPluggedIn()).thenReturn(true)
                batteryController.fake._isPluggedIn = true
                runCurrent()

                assertThat(underTest.shouldShowDreamButtonOnHub).isTrue()
@@ -80,8 +80,7 @@ class CommunalToDreamButtonViewModelTest : SysuiTestCase() {
    fun shouldShowDreamButtonOnHub_falseWhenNotPluggedIn() =
        with(kosmos) {
            runTest {
                whenever(batteryController.isPluggedIn()).thenReturn(false)
                runCurrent()
                batteryController.fake._isPluggedIn = false

                assertThat(underTest.shouldShowDreamButtonOnHub).isFalse()
            }
+5 −22
Original line number Diff line number Diff line
@@ -38,11 +38,9 @@ import com.android.systemui.statusbar.headsup.shared.StatusBarNoHunBehavior
import com.android.systemui.statusbar.notification.data.repository.FakeHeadsUpRowRepository
import com.android.systemui.statusbar.notification.stack.data.repository.headsUpNotificationRepository
import com.android.systemui.statusbar.notification.stack.domain.interactor.headsUpNotificationInteractor
import com.android.systemui.statusbar.policy.BatteryController
import com.android.systemui.statusbar.policy.batteryController
import com.android.systemui.statusbar.policy.fake
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.capture
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.launchIn
@@ -51,7 +49,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.verify
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters

@@ -185,23 +182,12 @@ class KeyguardStatusBarViewModelTest(flags: FlagsParameterization) : SysuiTestCa
            val latest by collectLastValue(underTest.isBatteryCharging)
            runCurrent()

            val captor = argumentCaptor<BatteryController.BatteryStateChangeCallback>()
            verify(batteryController).addCallback(capture(captor))
            val callback = captor.value

            callback.onBatteryLevelChanged(
                /* level= */ 2,
                /* pluggedIn= */ false,
                /* charging= */ true,
            )
            batteryController.fake._level = 2
            batteryController.fake._isPluggedIn = true

            assertThat(latest).isTrue()

            callback.onBatteryLevelChanged(
                /* level= */ 2,
                /* pluggedIn= */ true,
                /* charging= */ false,
            )
            batteryController.fake._isPluggedIn = false

            assertThat(latest).isFalse()
        }
@@ -212,12 +198,9 @@ class KeyguardStatusBarViewModelTest(flags: FlagsParameterization) : SysuiTestCa
            val job = underTest.isBatteryCharging.launchIn(this)
            runCurrent()

            val captor = argumentCaptor<BatteryController.BatteryStateChangeCallback>()
            verify(batteryController).addCallback(capture(captor))

            job.cancel()
            runCurrent()

            verify(batteryController).removeCallback(captor.value)
            assertThat(batteryController.fake.listeners).isEmpty()
        }
}
+49 −1
Original line number Diff line number Diff line
@@ -15,15 +15,63 @@
 */
package com.android.systemui.statusbar.phone.domain.interactor

import android.graphics.Rect
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.plugins.DarkIconDispatcher
import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher.DarkChange
import com.android.systemui.statusbar.phone.data.repository.DarkIconRepository
import com.android.systemui.statusbar.phone.domain.model.DarkState
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map

/** States pertaining to calculating colors for icons in dark mode. */
@SysUISingleton
class DarkIconInteractor @Inject constructor(private val repository: DarkIconRepository) {
    /** Dark-mode state for tinting icons. */
    fun darkState(displayId: Int): Flow<DarkState> =
        repository.darkState(displayId).map { DarkState(it.areas, it.tint) }
        repository.darkState(displayId).map { DarkState(it.areas, it.tint, it.darkIntensity) }

    /**
     * Given a display id: returns a flow of [IsAreaDark], a function that can tell you if a given
     * [Rect] should be tinted dark or not. This flow ignores [DarkChange.tint] and
     * [DarkChange.darkIntensity]
     */
    fun isAreaDark(displayId: Int): Flow<IsAreaDark> {
        return repository.darkState(displayId).toIsAreaDark()
    }

    companion object {
        /**
         * Convenience function to convert between the repository's [darkState] into [IsAreaDark]
         * type flows.
         */
        @JvmStatic
        fun Flow<DarkChange>.toIsAreaDark(): Flow<IsAreaDark> =
            map { darkChange ->
                    DarkStateWithoutIntensity(darkChange.areas, darkChange.darkIntensity < 0.5f)
                }
                .distinctUntilChanged()
                .map { darkState ->
                    IsAreaDark { viewBounds: Rect ->
                        if (DarkIconDispatcher.isInAreas(darkState.areas, viewBounds)) {
                            darkState.isDark
                        } else {
                            false
                        }
                    }
                }
                .conflate()
                .distinctUntilChanged()
    }
}

/** So we can map between [DarkState] and a single boolean, but based on intensity */
private data class DarkStateWithoutIntensity(val areas: Collection<Rect>, val isDark: Boolean)

/** Given a region on screen, determine if the foreground should be dark or light */
fun interface IsAreaDark {
    fun isDark(viewBounds: Rect): Boolean
}
+2 −0
Original line number Diff line number Diff line
@@ -24,4 +24,6 @@ data class DarkState(
    val areas: Collection<Rect>,
    /** Tint color to apply to UI elements that fall within [areas]. */
    val tint: Int,
    /** _How_ dark the area is. Less than 0.5 is dark, otherwise light */
    val darkIntensity: Float,
)
+163 −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.pipeline.battery.data.repository

import android.content.Context
import android.provider.Settings
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.shared.settings.data.repository.SystemSettingsRepository
import com.android.systemui.statusbar.policy.BatteryController
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.minutes
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.suspendCancellableCoroutine

/**
 * Repository-style state for battery information. Currently we just use the [BatteryController] as
 * our source of truth, but we could (should?) migrate away from that eventually.
 */
@SysUISingleton
class BatteryRepository
@Inject
constructor(
    @Application context: Context,
    @Background scope: CoroutineScope,
    @Background bgDispatcher: CoroutineDispatcher,
    private val controller: BatteryController,
    settingsRepository: SystemSettingsRepository,
) {
    private val batteryState: StateFlow<BatteryCallbackState> =
        conflatedCallbackFlow<(BatteryCallbackState) -> BatteryCallbackState> {
                val callback =
                    object : BatteryController.BatteryStateChangeCallback {
                        override fun onBatteryLevelChanged(
                            level: Int,
                            pluggedIn: Boolean,
                            charging: Boolean,
                        ) {
                            trySend { prev -> prev.copy(level = level, isPluggedIn = pluggedIn) }
                        }

                        override fun onPowerSaveChanged(isPowerSave: Boolean) {
                            trySend { prev -> prev.copy(isPowerSaveEnabled = isPowerSave) }
                        }

                        override fun onIsBatteryDefenderChanged(isBatteryDefender: Boolean) {
                            trySend { prev ->
                                prev.copy(isBatteryDefenderEnabled = isBatteryDefender)
                            }
                        }

                        override fun onBatteryUnknownStateChanged(isUnknown: Boolean) {
                            // If the state is unknown, then all other fields are invalid
                            trySend { prev ->
                                if (isUnknown) {
                                    // Forget everything before now
                                    BatteryCallbackState(isStateUnknown = true)
                                } else {
                                    prev.copy(isStateUnknown = false)
                                }
                            }
                        }
                    }

                controller.addCallback(callback)
                awaitClose { controller.removeCallback(callback) }
            }
            .scan(initial = BatteryCallbackState()) { state, eventF -> eventF(state) }
            .flowOn(bgDispatcher)
            .stateIn(scope, SharingStarted.Lazily, BatteryCallbackState())

    /**
     * True if the phone is plugged in. Note that this does not always mean the device is charging
     */
    val isPluggedIn = batteryState.map { it.isPluggedIn }

    /** Is power saver enabled */
    val isPowerSaveEnabled = batteryState.map { it.isPowerSaveEnabled }

    /** Battery defender means the device is plugged in but not charging to protect the battery */
    val isBatteryDefenderEnabled = batteryState.map { it.isBatteryDefenderEnabled }

    /** The current level [0-100] */
    val level = batteryState.map { it.level }

    /** State unknown means that we can't detect a battery */
    val isStateUnknown = batteryState.map { it.isStateUnknown }

    /**
     * [Settings.System.SHOW_BATTERY_PERCENT]. A user setting to indicate whether we should show the
     * battery percentage in the home screen status bar
     */
    val isShowBatteryPercentSettingEnabled = run {
        val default =
            context.resources.getBoolean(
                com.android.internal.R.bool.config_defaultBatteryPercentageSetting
            )
        settingsRepository
            .boolSetting(name = Settings.System.SHOW_BATTERY_PERCENT, defaultValue = default)
            .flowOn(bgDispatcher)
            .stateIn(scope, SharingStarted.Lazily, default)
    }

    /** Get and re-fetch the estimate every 2 minutes while active */
    private val estimate: Flow<String?> = flow {
        while (true) {
            val estimate = fetchEstimate()
            emit(estimate)
            delay(2.minutes)
        }
    }

    /**
     * If available, this flow yields a string that describes the approximate time remaining for the
     * current battery charge and usage information. While subscribed, the estimate is updated every
     * 2 minutes.
     */
    val batteryTimeRemainingEstimate: Flow<String?> = estimate.flowOn(bgDispatcher)

    private suspend fun fetchEstimate() = suspendCancellableCoroutine { continuation ->
        val callback =
            BatteryController.EstimateFetchCompletion { estimate -> continuation.resume(estimate) }

        controller.getEstimatedTimeRemainingString(callback)
    }
}

/** Data object to track the current battery callback state */
private data class BatteryCallbackState(
    val level: Int? = null,
    val isPluggedIn: Boolean = false,
    val isPowerSaveEnabled: Boolean = false,
    val isBatteryDefenderEnabled: Boolean = false,
    val isStateUnknown: Boolean = false,
)
Loading