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

Commit d88a2b33 authored by Caitlin Shkuratov's avatar Caitlin Shkuratov
Browse files

[SB][Chips] Allow timer chips to count down to a time in the future.

The current timer chip implementation only counts up from a time in the
past, and it shows 00:00 if the time is in the future. But apps could
also set their notification time to be in the future and  set
`isCountDown=true`, meaning that they want the notification UI to count
down to that future time.

This CL implements that behavior for both non-Compose chips and Compose
chips. I don't expect a lot of apps to use this feature, but we should
have it in case. The Compose implementation is slightly better (if the
duration would be negative, we hide it), but the non-Compose
implementation works just like normal notifications so I think it's okay
since we plan to launch the Compose chips at the same time as the
notification chips.

Fixes: 400786236
Fixes: 401464026
Bug: 364653005
Flag: com.android.systemui.status_bar_notification_chips

Test: Post notif with `when` 15 seconds in the future,
showsChronomter=true, isCountDown=true ->
  - ChipMod on:  chip counts down from 00:15, 00:14, ... 00:00,
                 then hides the text.
  - ChipMod off: chip counts down from 00:15, 00:14, ... 00:00 ->
                 -00:01, -00:02, ...
Test: Post notif with `when` 15 seconds in the future,
showsChronometer=true, isCountDown=false ->
  - ChipMod on:  chip shows nothing for 15 seconds, then starts showing
                 00:00, 00:01, etc.
  - ChipMod off: chip show -00:15, -00:14, ... 00:00, 00:01 ...

Test: Post notif with `when` 15 seconds in the past,
showChronometer=true, isCountDown=true ->
  - ChipMod on:  chip never shows any text
  - ChipMod off: chip shows -00:15, -00:16, ...
Test: Post notif with `when` 15 seconds in the past,
showChronometer=true, isCountDown=false ->
  - ChipMod on:  chip starts at 00:15 and counts up with 00:16, 00:17...
  - ChipMod off: chip starts at 00:15 and counts up with 00:16, 00:17...

Test: atest ChronometerStateTest NotifChipsViewModelTest
Test: Test the above with ChipsModernization both off and on

Change-Id: I20d1cf44e5ae2937ac64ceacb7ea045b9fe9989d
parent 91a4647e
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -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
+172 −21
Original line number Diff line number Diff line
@@ -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 */
+2 −2
Original line number Diff line number Diff line
@@ -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,
                )
+4 −1
Original line number Diff line number Diff line
@@ -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()
    }
}
+5 −1
Original line number Diff line number Diff line
@@ -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
Loading