summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/TimeRemainingStateTest.kt237
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/ChipContent.kt23
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/TimeRemainingState.kt122
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())
+ }
+ }
+}