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

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

Merge "[Sb][Chips] Add support for composable short time delta chips" into main

parents 1eda567b 783ecd1c
Loading
Loading
Loading
Loading
+237 −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.chips.ui.viewmodel

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.R.string.duration_hours_medium
import com.android.internal.R.string.duration_minutes_medium
import com.android.internal.R.string.now_string_shortest
import com.android.systemui.SysuiTestCase
import com.google.common.truth.Truth.assertThat
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class TimeRemainingStateTest : SysuiTestCase() {

    private var fakeTimeSource: MutableTimeSource = MutableTimeSource()
    // We need a non-zero start time to advance to. This is needed to ensure `TimeRemainingState` is
    // updated at least once.
    private val startTime = 1.seconds.inWholeMilliseconds

    @Test
    fun timeRemainingState_pastTime() = runTest {
        val state = TimeRemainingState(fakeTimeSource, startTime - 62.seconds.inWholeMilliseconds)
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData).isNull()
        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_lessThanOneMinute() = runTest {
        val state = TimeRemainingState(fakeTimeSource, startTime + 59.seconds.inWholeMilliseconds)
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(now_string_shortest)
        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_lessThanOneMinuteInThePast() = runTest {
        val state = TimeRemainingState(fakeTimeSource, startTime - 59.seconds.inWholeMilliseconds)
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(now_string_shortest)
        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_oneMinute() = runTest {
        val state = TimeRemainingState(fakeTimeSource, startTime + 60.seconds.inWholeMilliseconds)
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_minutes_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(1)
        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_lessThanOneHour() = runTest {
        val state = TimeRemainingState(fakeTimeSource, startTime + 59.minutes.inWholeMilliseconds)
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_minutes_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(59)
        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_oneHour() = runTest {
        val state = TimeRemainingState(fakeTimeSource, startTime + 60.minutes.inWholeMilliseconds)
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_hours_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(1)
        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_betweenOneAndTwoHours() = runTest {
        val state = TimeRemainingState(fakeTimeSource, startTime + 119.minutes.inWholeMilliseconds)
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)

        assertThat(state.timeRemainingData).isNotNull()
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_hours_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(1)
        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_betweenFiveAndSixHours() = runTest {
        val state = TimeRemainingState(fakeTimeSource, startTime + 320.minutes.inWholeMilliseconds)
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_hours_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(5)
        job.cancelAndJoin()
    }

    fun timeRemainingState_moreThan24Hours() = runTest {
        val state =
            TimeRemainingState(fakeTimeSource, startTime + (25 * 60.minutes.inWholeMilliseconds))
        val job = launch { state.run() }

        fakeTimeSource.time = startTime
        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData).isNull()

        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_updateFromMinuteToNow() = runTest {
        fakeTimeSource.time = startTime
        val state = TimeRemainingState(fakeTimeSource, startTime + 119.seconds.inWholeMilliseconds)
        val job = launch { state.run() }

        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_minutes_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(1)

        fakeTimeSource.time += 59.seconds.inWholeMilliseconds
        advanceTimeBy(59.seconds.inWholeMilliseconds)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_minutes_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(1)

        fakeTimeSource.time += 1.seconds.inWholeMilliseconds
        advanceTimeBy(1.seconds.inWholeMilliseconds)
        assertThat(state.timeRemainingData!!.first).isEqualTo(now_string_shortest)

        job.cancelAndJoin()
    }

    fun timeRemainingState_updateFromNowToEmpty() = runTest {
        fakeTimeSource.time = startTime
        val state = TimeRemainingState(fakeTimeSource, startTime)
        val job = launch { state.run() }

        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(now_string_shortest)

        fakeTimeSource.time += 62.seconds.inWholeMilliseconds
        advanceTimeBy(62.seconds.inWholeMilliseconds)
        assertThat(state.timeRemainingData).isNull()

        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_updateFromHourToMinutes() = runTest {
        fakeTimeSource.time = startTime
        val state = TimeRemainingState(fakeTimeSource, startTime + 119.minutes.inWholeMilliseconds)
        val job = launch { state.run() }

        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_hours_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(1)

        fakeTimeSource.time += 59.minutes.inWholeMilliseconds
        advanceTimeBy(59.minutes.inWholeMilliseconds)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_hours_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(1)

        fakeTimeSource.time += 1.seconds.inWholeMilliseconds
        advanceTimeBy(1.seconds.inWholeMilliseconds)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_minutes_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(59)

        job.cancelAndJoin()
    }

    @Test
    fun timeRemainingState_showAfterLessThan24Hours() = runTest {
        fakeTimeSource.time = startTime
        val state = TimeRemainingState(fakeTimeSource, startTime + 25.hours.inWholeMilliseconds)
        val job = launch { state.run() }

        advanceTimeBy(startTime)
        assertThat(state.timeRemainingData).isNull()

        fakeTimeSource.time += 1.hours.inWholeMilliseconds + 1.seconds.inWholeMilliseconds
        advanceTimeBy(1.hours.inWholeMilliseconds + 1.seconds.inWholeMilliseconds)
        assertThat(state.timeRemainingData!!.first).isEqualTo(duration_hours_medium)
        assertThat(state.timeRemainingData!!.second).isEqualTo(23)

        job.cancelAndJoin()
    }

    /** A fake implementation of [TimeSource] that allows the caller to set the current time */
    private class MutableTimeSource(var time: Long = 0L) : TimeSource {
        override fun getCurrentTime(): Long {
            return time
        }
    }
}
+22 −1
Original line number Diff line number Diff line
@@ -37,7 +37,9 @@ import androidx.compose.ui.unit.constrain
import androidx.compose.ui.unit.dp
import com.android.systemui.res.R
import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel
import com.android.systemui.statusbar.chips.ui.viewmodel.formatTimeRemainingData
import com.android.systemui.statusbar.chips.ui.viewmodel.rememberChronometerState
import com.android.systemui.statusbar.chips.ui.viewmodel.rememberTimeRemainingState
import kotlin.math.min

@Composable
@@ -119,7 +121,26 @@ fun ChipContent(viewModel: OngoingActivityChipModel.Active, modifier: Modifier =
        }

        is OngoingActivityChipModel.Active.ShortTimeDelta -> {
            // TODO(b/372657935): Implement ShortTimeDelta content in compose.
            val timeRemainingState = rememberTimeRemainingState(futureTimeMillis = viewModel.time)

            timeRemainingState.timeRemainingData?.let {
                val text = formatTimeRemainingData(it)
                Text(
                    text = text,
                    style = textStyle,
                    color = textColor,
                    softWrap = false,
                    modifier =
                        modifier.hideTextIfDoesNotFit(
                            text = text,
                            textStyle = textStyle,
                            textMeasurer = textMeasurer,
                            maxTextWidth = maxTextWidth,
                            startPadding = startPadding,
                            endPadding = endPadding,
                        ),
                )
            }
        }

        is OngoingActivityChipModel.Active.IconOnly -> {
+122 −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.chips.ui.viewmodel

import android.os.SystemClock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlinx.coroutines.delay

/**
 * Manages state and updates for the duration remaining between now and a given time in the future.
 */
class TimeRemainingState(private val timeSource: TimeSource, private val futureTimeMillis: Long) {
    private var durationRemaining by mutableStateOf(Duration.ZERO)
    private var startTimeMillis: Long = 0

    /**
     * [Pair] representing the time unit and its value.
     *
     * @property first the string resource ID corresponding to the time unit (e.g., minutes, hours).
     * @property second the time value of the duration unit. Null if time is less than a minute or
     *   past.
     */
    val timeRemainingData by derivedStateOf { getTimeRemainingData(durationRemaining) }

    suspend fun run() {
        startTimeMillis = timeSource.getCurrentTime()
        while (true) {
            val currentTime = timeSource.getCurrentTime()
            durationRemaining =
                (futureTimeMillis - currentTime).toDuration(DurationUnit.MILLISECONDS)
            // No need to update if duration is more than 1 minute in the past. Because, we will
            // stop displaying anything.
            if (durationRemaining.inWholeMilliseconds < -1.minutes.inWholeMilliseconds) {
                break
            }
            val delaySkewMillis = (currentTime - startTimeMillis) % 1000L
            delay(calculateNextUpdateDelay(durationRemaining) - delaySkewMillis)
        }
    }

    private fun calculateNextUpdateDelay(duration: Duration): Long {
        val durationAbsolute = duration.absoluteValue
        return when {
            durationAbsolute.inWholeHours < 1 -> {
                1000 + ((durationAbsolute.inWholeMilliseconds % 1.minutes.inWholeMilliseconds))
            }
            durationAbsolute.inWholeHours < 24 -> {
                1000 + (durationAbsolute.inWholeMilliseconds % 1.hours.inWholeMilliseconds)
            }
            else -> 1000 + (durationAbsolute.inWholeMilliseconds % 24.hours.inWholeMilliseconds)
        }
    }
}

/** Remember and manage the TimeRemainingState */
@Composable
fun rememberTimeRemainingState(
    futureTimeMillis: Long,
    timeSource: TimeSource = remember { TimeSource { SystemClock.elapsedRealtime() } },
): TimeRemainingState {

    val state =
        remember(timeSource, futureTimeMillis) { TimeRemainingState(timeSource, futureTimeMillis) }
    val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(lifecycleOwner, timeSource, futureTimeMillis) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { state.run() }
    }

    return state
}

private fun getTimeRemainingData(duration: Duration): Pair<Int, Long?>? {
    return when {
        duration.inWholeMinutes <= -1 -> null
        duration.inWholeMinutes < 1 -> Pair(com.android.internal.R.string.now_string_shortest, null)
        duration.inWholeHours < 1 ->
            Pair(com.android.internal.R.string.duration_minutes_medium, duration.inWholeMinutes)
        duration.inWholeDays < 1 ->
            Pair(com.android.internal.R.string.duration_hours_medium, duration.inWholeHours)
        else -> null
    }
}

/** Formats the time remaining data into a user-readable string. */
@Composable
fun formatTimeRemainingData(resourcePair: Pair<Int, Long?>): String {
    return resourcePair.let { (resourceId, time) ->
        when (time) {
            null -> stringResource(resourceId)
            else -> stringResource(resourceId, time.toInt())
        }
    }
}