diff options
8 files changed, 263 insertions, 53 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt index 4993b5661373..b5cfc7e9080d 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModelTest.kt @@ -573,6 +573,8 @@ class NotifChipsViewModelTest : SysuiTestCase() { assertThat(latest!![0]).isInstanceOf(OngoingActivityChipModel.Active.Timer::class.java) assertThat((latest!![0] as OngoingActivityChipModel.Active.Timer).startTimeMs) .isEqualTo(whenElapsed) + assertThat((latest!![0] as OngoingActivityChipModel.Active.Timer).isEventInFuture) + .isFalse() } @Test @@ -608,6 +610,8 @@ class NotifChipsViewModelTest : SysuiTestCase() { assertThat(latest!![0]).isInstanceOf(OngoingActivityChipModel.Active.Timer::class.java) assertThat((latest!![0] as OngoingActivityChipModel.Active.Timer).startTimeMs) .isEqualTo(whenElapsed) + assertThat((latest!![0] as OngoingActivityChipModel.Active.Timer).isEventInFuture) + .isTrue() } @Test diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt index 4e92540396d3..cd9970cfa614 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerStateTest.kt @@ -35,55 +35,153 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ChronometerStateTest : SysuiTestCase() { - private lateinit var mockTimeSource: MutableTimeSource + private lateinit var fakeTimeSource: MutableTimeSource @Before fun setup() { - mockTimeSource = MutableTimeSource() + fakeTimeSource = MutableTimeSource() } @Test - fun initialText_isCorrect() = runTest { - val state = ChronometerState(mockTimeSource, 0L) - assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(0)) + fun initialText_isEventInFutureFalse_timeIsNow() = runTest { + fakeTimeSource.time = 3_000 + val state = + ChronometerState(fakeTimeSource, eventTimeMillis = 3_000, isEventInFuture = false) + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 0)) } @Test - fun textUpdates_withTime() = runTest { - val startTime = 1000L - val state = ChronometerState(mockTimeSource, startTime) + fun initialText_isEventInFutureFalse_timeInPast() = runTest { + fakeTimeSource.time = 3_000 + val state = + ChronometerState(fakeTimeSource, eventTimeMillis = 1_000, isEventInFuture = false) + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 2)) + } + + @Test + fun initialText_isEventInFutureFalse_timeInFuture() = runTest { + fakeTimeSource.time = 3_000 + val state = + ChronometerState(fakeTimeSource, eventTimeMillis = 5_000, isEventInFuture = false) + // When isEventInFuture=false, eventTimeMillis needs to be in the past if we want text to + // show + assertThat(state.currentTimeText).isNull() + } + + @Test + fun initialText_isEventInFutureTrue_timeIsNow() = runTest { + fakeTimeSource.time = 3_000 + val state = + ChronometerState(fakeTimeSource, eventTimeMillis = 3_000, isEventInFuture = true) + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 0)) + } + + @Test + fun initialText_isEventInFutureTrue_timeInFuture() = runTest { + fakeTimeSource.time = 3_000 + val state = + ChronometerState(fakeTimeSource, eventTimeMillis = 5_000, isEventInFuture = true) + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 2)) + } + + @Test + fun initialText_isEventInFutureTrue_timeInPast() = runTest { + fakeTimeSource.time = 3_000 + val state = + ChronometerState(fakeTimeSource, eventTimeMillis = 1_000, isEventInFuture = true) + // When isEventInFuture=true, eventTimeMillis needs to be in the future if we want text to + // show + assertThat(state.currentTimeText).isNull() + } + + @Test + fun textUpdates_isEventInFutureFalse_timeInPast() = runTest { + val eventTime = 1000L + val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = false) val job = launch { state.run() } val elapsedTime = 5000L - mockTimeSource.time = startTime + elapsedTime + fakeTimeSource.time = eventTime + elapsedTime advanceTimeBy(elapsedTime) assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(elapsedTime / 1000)) + val additionalTime = 6000L + fakeTimeSource.time += additionalTime + advanceTimeBy(additionalTime) + assertThat(state.currentTimeText) + .isEqualTo(formatElapsedTime((elapsedTime + additionalTime) / 1000)) + job.cancelAndJoin() } @Test - fun textUpdates_toLargerValue() = runTest { - val startTime = 1000L - val state = ChronometerState(mockTimeSource, startTime) + fun textUpdates_isEventInFutureFalse_timeChangesFromFutureToPast() = runTest { + val eventTime = 15_000L + val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = false) val job = launch { state.run() } - val elapsedTime = 15000L - mockTimeSource.time = startTime + elapsedTime - advanceTimeBy(elapsedTime) - assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(elapsedTime / 1000)) + // WHEN the time is 5 but the eventTime is 15 + fakeTimeSource.time = 5_000L + advanceTimeBy(5_000L) + // THEN no text is shown + assertThat(state.currentTimeText).isNull() + + // WHEN the time advances to 40 + fakeTimeSource.time = 40_000L + advanceTimeBy(35_000) + // THEN text is shown as 25 seconds (40 - 15) + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 25)) job.cancelAndJoin() } @Test - fun textUpdates_afterResettingBase() = runTest { + fun textUpdates_isEventInFutureTrue_timeInFuture() = runTest { + val eventTime = 15_000L + val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = true) + val job = launch { state.run() } + + fakeTimeSource.time = 5_000L + advanceTimeBy(5_000L) + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 10)) + + val additionalTime = 6000L + fakeTimeSource.time += additionalTime + advanceTimeBy(additionalTime) + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 4)) + + job.cancelAndJoin() + } + + @Test + fun textUpdates_isEventInFutureTrue_timeChangesFromFutureToPast() = runTest { + val eventTime = 15_000L + val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = true) + val job = launch { state.run() } + + // WHEN the time is 5 and the eventTime is 15 + fakeTimeSource.time = 5_000L + advanceTimeBy(5_000L) + // THEN 10 seconds is shown + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 10)) + + // WHEN the time advances to 40 (past the event time) + fakeTimeSource.time = 40_000L + advanceTimeBy(35_000) + // THEN no text is shown + assertThat(state.currentTimeText).isNull() + + job.cancelAndJoin() + } + + @Test + fun textUpdates_afterResettingBase_isEventInFutureFalse() = runTest { val initialElapsedTime = 30000L val startTime = 50000L - val state = ChronometerState(mockTimeSource, startTime) + val state = ChronometerState(fakeTimeSource, startTime, isEventInFuture = false) val job = launch { state.run() } - mockTimeSource.time = startTime + initialElapsedTime + fakeTimeSource.time = startTime + initialElapsedTime advanceTimeBy(initialElapsedTime) assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(initialElapsedTime / 1000)) @@ -91,15 +189,68 @@ class ChronometerStateTest : SysuiTestCase() { val newElapsedTime = 5000L val newStartTime = 100000L - val newState = ChronometerState(mockTimeSource, newStartTime) + val newState = ChronometerState(fakeTimeSource, newStartTime, isEventInFuture = false) val newJob = launch { newState.run() } - mockTimeSource.time = newStartTime + newElapsedTime + fakeTimeSource.time = newStartTime + newElapsedTime advanceTimeBy(newElapsedTime) assertThat(newState.currentTimeText).isEqualTo(formatElapsedTime(newElapsedTime / 1000)) newJob.cancelAndJoin() } + + @Test + fun textUpdates_afterResettingBase_isEventInFutureTrue() = runTest { + val initialElapsedTime = 40_000L + val eventTime = 50_000L + val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = true) + val job = launch { state.run() } + + fakeTimeSource.time = initialElapsedTime + advanceTimeBy(initialElapsedTime) + // Time should be 50 - 40 = 10 + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 10)) + + job.cancelAndJoin() + + val newElapsedTime = 75_000L + val newEventTime = 100_000L + val newState = ChronometerState(fakeTimeSource, newEventTime, isEventInFuture = true) + val newJob = launch { newState.run() } + + fakeTimeSource.time = newElapsedTime + advanceTimeBy(newElapsedTime - initialElapsedTime) + // Time should be 100 - 75 = 25 + assertThat(newState.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 25)) + + newJob.cancelAndJoin() + } + + @Test + fun textUpdates_afterResettingisEventInFuture() = runTest { + val initialElapsedTime = 40_000L + val eventTime = 50_000L + val state = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = true) + val job = launch { state.run() } + + fakeTimeSource.time = initialElapsedTime + advanceTimeBy(initialElapsedTime) + // Time should be 50 - 40 = 10 + assertThat(state.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 10)) + + job.cancelAndJoin() + + val newElapsedTime = 70_000L + val newState = ChronometerState(fakeTimeSource, eventTime, isEventInFuture = false) + val newJob = launch { newState.run() } + + fakeTimeSource.time = newElapsedTime + advanceTimeBy(newElapsedTime - initialElapsedTime) + // Time should be 70 - 50 = 20 + assertThat(newState.currentTimeText).isEqualTo(formatElapsedTime(/* elapsedSeconds= */ 20)) + + newJob.cancelAndJoin() + } } /** A fake implementation of [TimeSource] that allows the caller to set the current time */ diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt index 2fe627020ebf..06656b744a5a 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/notification/ui/viewmodel/NotifChipsViewModel.kt @@ -141,7 +141,7 @@ constructor( // When we're promoting notifications automatically, the `when` time set on the // notification will likely just be set to the current time, which would cause the chip // to always show "now". We don't want early testers to get that experience since it's - // not what will happen at launch, so just don't show any time. + // not what will happen at launch, so just don't show any time.onometerstate return OngoingActivityChipModel.Active.IconOnly( this.key, icon, @@ -194,12 +194,12 @@ constructor( } } is PromotedNotificationContentModel.When.Chronometer -> { - // TODO(b/364653005): Check isCountDown and support CountDown. return OngoingActivityChipModel.Active.Timer( this.key, icon, colors, startTimeMs = this.promotedContent.time.elapsedRealtimeMillis, + isEventInFuture = this.promotedContent.time.isCountDown, onClickListenerLegacy, clickBehavior, ) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt index 2032ec8af78c..1eb46d8cc3d5 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/ChipChronometerBinder.kt @@ -16,6 +16,7 @@ package com.android.systemui.statusbar.chips.ui.binder +import android.annotation.ElapsedRealtimeLong import com.android.systemui.statusbar.chips.ui.view.ChipChronometer object ChipChronometerBinder { @@ -25,9 +26,11 @@ object ChipChronometerBinder { * @param startTimeMs the time this event started, relative to * [com.android.systemui.util.time.SystemClock.elapsedRealtime]. See * [android.widget.Chronometer.setBase]. + * @param isCountDown see [android.widget.Chronometer.setCountDown]. */ - fun bind(startTimeMs: Long, view: ChipChronometer) { + fun bind(@ElapsedRealtimeLong startTimeMs: Long, isCountDown: Boolean, view: ChipChronometer) { view.base = startTimeMs + view.isCountDown = isCountDown view.start() } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt index 6f8552738d33..77e0dde3dec5 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/binder/OngoingActivityChipBinder.kt @@ -315,7 +315,11 @@ object OngoingActivityChipBinder { chipShortTimeDeltaView.visibility = View.GONE } is OngoingActivityChipModel.Active.Timer -> { - ChipChronometerBinder.bind(chipModel.startTimeMs, chipTimeView) + ChipChronometerBinder.bind( + chipModel.startTimeMs, + chipModel.isEventInFuture, + chipTimeView, + ) chipTimeView.visibility = View.VISIBLE chipTextView.visibility = View.GONE 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 55d753662a65..f650d8d7d10d 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 @@ -74,25 +74,30 @@ fun ChipContent(viewModel: OngoingActivityChipModel.Active, modifier: Modifier = val textMeasurer = rememberTextMeasurer() when (viewModel) { is OngoingActivityChipModel.Active.Timer -> { - val timerState = rememberChronometerState(startTimeMillis = viewModel.startTimeMs) - val text = timerState.currentTimeText - Text( - text = text, - style = textStyle, - color = textColor, - softWrap = false, - modifier = - modifier - .hideTextIfDoesNotFit( - text = text, - textStyle = textStyle, - textMeasurer = textMeasurer, - maxTextWidth = maxTextWidth, - startPadding = startPadding, - endPadding = endPadding, - ) - .neverDecreaseWidth(density), - ) + val timerState = + rememberChronometerState( + eventTimeMillis = viewModel.startTimeMs, + isCountDown = viewModel.isEventInFuture, + ) + timerState.currentTimeText?.let { text -> + Text( + text = text, + style = textStyle, + color = textColor, + softWrap = false, + modifier = + modifier + .hideTextIfDoesNotFit( + text = text, + textStyle = textStyle, + textMeasurer = textMeasurer, + maxTextWidth = maxTextWidth, + startPadding = startPadding, + endPadding = endPadding, + ) + .neverDecreaseWidth(density), + ) + } } is OngoingActivityChipModel.Active.Countdown -> { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt index 8e470742f174..106a36313190 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt @@ -105,6 +105,12 @@ sealed class OngoingActivityChipModel { * [android.widget.Chronometer.setBase]. */ @ElapsedRealtimeLong val startTimeMs: Long, + /** + * True if this chip represents an event starting in the future and false if this chip + * represents an event that has already started. If true, [startTimeMs] should be in the + * future. Otherwise, [startTimeMs] should be in the past. + */ + val isEventInFuture: Boolean = false, override val onClickListenerLegacy: View.OnClickListener?, override val clickBehavior: ClickBehavior, override val isHidden: Boolean = false, diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt index 62789782d0a9..7402583cafe2 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/viewmodel/ChronometerState.kt @@ -15,6 +15,7 @@ */ package com.android.systemui.statusbar.chips.ui.viewmodel +import android.annotation.ElapsedRealtimeLong import android.os.SystemClock import android.text.format.DateUtils.formatElapsedTime import androidx.compose.runtime.Composable @@ -27,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle +import kotlin.math.absoluteValue import kotlinx.coroutines.delay /** Platform-optimized interface for getting current time */ @@ -34,18 +36,50 @@ fun interface TimeSource { fun getCurrentTime(): Long } -/** Holds and manages the state for a Chronometer */ -class ChronometerState(private val timeSource: TimeSource, private val startTimeMillis: Long) { - private var currentTimeMillis by mutableLongStateOf(0L) +/** + * Holds and manages the state for a Chronometer, which shows a timer in a format like "MM:SS" or + * "H:MM:SS". + * + * If [isEventInFuture] is false, then this Chronometer is counting up from an event that started in + * the past, like a phone call that was answered. [eventTimeMillis] represents the time the event + * started and the timer will tick up: 04:00, 04:01, ... No timer is shown if [eventTimeMillis] is + * in the future and [isEventInFuture] is false. + * + * If [isEventInFuture] is true, then this Chronometer is counting down to an event that will occur + * in the future, like a future meeting. [eventTimeMillis] represents the time the event will occur + * and the timer will tick down: 04:00, 03:59, ... No timer is shown if [eventTimeMillis] is in the + * past and [isEventInFuture] is true. + */ +class ChronometerState( + private val timeSource: TimeSource, + @ElapsedRealtimeLong private val eventTimeMillis: Long, + private val isEventInFuture: Boolean, +) { + private var currentTimeMillis by mutableLongStateOf(timeSource.getCurrentTime()) private val elapsedTimeMillis: Long - get() = maxOf(0L, currentTimeMillis - startTimeMillis) + get() = + if (isEventInFuture) { + eventTimeMillis - currentTimeMillis + } else { + currentTimeMillis - eventTimeMillis + } - val currentTimeText: String by derivedStateOf { formatElapsedTime(elapsedTimeMillis / 1000) } + /** + * The current timer string in a format like "MM:SS" or "H:MM:SS", or null if we shouldn't show + * the timer string. + */ + val currentTimeText: String? by derivedStateOf { + if (elapsedTimeMillis < 0) { + null + } else { + formatElapsedTime(elapsedTimeMillis / 1000) + } + } suspend fun run() { while (true) { currentTimeMillis = timeSource.getCurrentTime() - val delaySkewMillis = (currentTimeMillis - startTimeMillis) % 1000L + val delaySkewMillis = (eventTimeMillis - currentTimeMillis).absoluteValue % 1000L delay(1000L - delaySkewMillis) } } @@ -54,13 +88,16 @@ class ChronometerState(private val timeSource: TimeSource, private val startTime /** Remember and manage the ChronometerState */ @Composable fun rememberChronometerState( - startTimeMillis: Long, + eventTimeMillis: Long, + isCountDown: Boolean, timeSource: TimeSource = remember { TimeSource { SystemClock.elapsedRealtime() } }, ): ChronometerState { val state = - remember(timeSource, startTimeMillis) { ChronometerState(timeSource, startTimeMillis) } + remember(timeSource, eventTimeMillis, isCountDown) { + ChronometerState(timeSource, eventTimeMillis, isCountDown) + } val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(lifecycleOwner, timeSource, startTimeMillis) { + LaunchedEffect(lifecycleOwner, timeSource, eventTimeMillis) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { state.run() } } |