diff options
3 files changed, 381 insertions, 1 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/TimeRemainingStateTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/TimeRemainingStateTest.kt new file mode 100644 index 000000000000..5dc59e893715 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/TimeRemainingStateTest.kt @@ -0,0 +1,237 @@ +/* + * 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 + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt index 2501aa59c375..5242feac898b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt @@ -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 -> { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/TimeRemainingState.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/TimeRemainingState.kt new file mode 100644 index 000000000000..eb6ebcaa5796 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/TimeRemainingState.kt @@ -0,0 +1,122 @@ +/* + * 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()) + } + } +} |