Loading core/java/android/widget/Chronometer.java +28 −8 Original line number Diff line number Diff line Loading @@ -16,6 +16,9 @@ package android.widget; import static android.text.format.DateUtils.MINUTE_IN_MILLIS; import static android.text.format.DateUtils.SECOND_IN_MILLIS; import static java.util.Objects.requireNonNull; import android.annotation.ElapsedRealtimeLong; Loading Loading @@ -70,6 +73,13 @@ import java.util.function.LongSupplier; public class Chronometer extends TextView { private static final String TAG = "Chronometer"; /** * In adaptive format, when displaying an elapsed/remaining duration greater than or equal to * this number of minutes, seconds will not be shown (which also means the chronometer will tick * on the minute instead of on the second). */ private static final int ADAPTIVE_MINUTES_WITHOUT_SECONDS = 3; /** * A callback that notifies when the chronometer has incremented on its own. */ Loading Loading @@ -433,7 +443,7 @@ public class Chronometer extends TextView { } } else if (minutes.getNumber().intValue() != 0) { partsList.add(minutes); if (minutes.getNumber().intValue() < 3) { if (minutes.getNumber().intValue() < ADAPTIVE_MINUTES_WITHOUT_SECONDS) { partsList.add(seconds); } } Loading Loading @@ -467,7 +477,7 @@ public class Chronometer extends TextView { if (running) { updateText(mElapsedRealtimeClock.getAsLong()); dispatchChronometerTick(); postTickOnNextSecond(); postTickOnNextChange(); } else { removeCallbacks(mTickRunnable); } Loading @@ -481,22 +491,32 @@ public class Chronometer extends TextView { if (mRunning) { updateText(mElapsedRealtimeClock.getAsLong()); dispatchChronometerTick(); postTickOnNextSecond(); postTickOnNextChange(); } } }; private void postTickOnNextSecond() { private void postTickOnNextChange() { long nowMillis = mNow; // In adaptive format, ticks are every 1 minute instead of 1 second, if the time elapsed // or remaining is >= 3 minutes. Thus for time > 3 minutes the tick will be "on the minute" // and for lower than that it's "on the second". long periodInMillis = mUseAdaptiveFormat && Math.abs(nowMillis - mBase) > ADAPTIVE_MINUTES_WITHOUT_SECONDS * MINUTE_IN_MILLIS ? MINUTE_IN_MILLIS : SECOND_IN_MILLIS; long delayMillis; if (mCountDown) { delayMillis = (mBase - nowMillis) % 1000; delayMillis = (mBase - nowMillis) % periodInMillis; if (delayMillis <= 0) { delayMillis += 1000; delayMillis += periodInMillis; } } else { delayMillis = 1000 - (Math.abs(nowMillis - mBase) % 1000); delayMillis = periodInMillis - (Math.abs(nowMillis - mBase) % periodInMillis); } // Aim for 3 milliseconds into the next second so we don't update exactly on the second delayMillis += 3; postDelayed(mTickRunnable, delayMillis); Loading @@ -511,7 +531,7 @@ public class Chronometer extends TextView { private static final int MIN_IN_SEC = 60; private static final int HOUR_IN_SEC = MIN_IN_SEC*60; private static String formatDuration(long ms) { int duration = (int) (ms / DateUtils.SECOND_IN_MILLIS); int duration = (int) (ms / SECOND_IN_MILLIS); if (duration < 0) { duration = -duration; } Loading core/tests/coretests/src/android/widget/ChronometerTest.java +91 −0 Original line number Diff line number Diff line Loading @@ -17,9 +17,16 @@ package android.widget; import static android.text.format.DateUtils.MINUTE_IN_MILLIS; import static android.text.format.DateUtils.SECOND_IN_MILLIS; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertArrayEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static java.time.temporal.ChronoUnit.MINUTES; import static java.time.temporal.ChronoUnit.SECONDS; Loading @@ -41,6 +48,7 @@ import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; /** Loading Loading @@ -307,6 +315,89 @@ public class ChronometerTest extends ActivityInstrumentationTestCase2<Chronomete assertThat(chronometer.getText().toString()).isEqualTo("1m 0s"); } @UiThreadTest public void testScheduledTicks() { long base = SystemClock.elapsedRealtime(); // Non-adaptive: Always on the next second, regardless of stopwatch or countdown. verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ false, base, /* now= */ base + 200, /* expectedDelay= */ 800); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ false, base, /* now= */ base + 5 * SECOND_IN_MILLIS, /* expectedDelay= */ 1000); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ false, base, /* now= */ base + 30 * SECOND_IN_MILLIS + 300, /* expectedDelay= */ 700); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ false, base, /* now= */ base + 45 * MINUTE_IN_MILLIS + 900, /* expectedDelay= */ 100); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ true, base, /* now= */ base - 200, /* expectedDelay= */ 200); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ true, base, /* now= */ base - 10 * SECOND_IN_MILLIS, /* expectedDelay= */ 1000); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ true, base, /* now= */ base + 200, // overrun countdown /* expectedDelay= */ 800); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ true, base, /* now= */ base + 4 * SECOND_IN_MILLIS + 300, // overrun countdown /* expectedDelay= */ 700); // Adaptive stopwatch, 2:20 elapsed (< 3 minutes) -> on the second. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ false, base, /* now= */ base + 2 * MINUTE_IN_MILLIS + 20 * SECOND_IN_MILLIS + 100, /* expectedDelay= */ 900); // Adaptive stopwatch, more than than 3 minutes elapsed -> on the next minute. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ false, base, /* now= */ base + 5 * MINUTE_IN_MILLIS + 10 * SECOND_IN_MILLIS + 300, /* expectedDelay= */ 49 * SECOND_IN_MILLIS + 700); // Adaptive timer, more than than 3 minutes remaining -> on the next minute. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ true, base, /* now= */ base - 4 * MINUTE_IN_MILLIS - 10 * SECOND_IN_MILLIS - 300, /* expectedDelay= */ 10 * SECOND_IN_MILLIS + 300); // Adaptive timer, slightly more than than 3 minutes remaining -> on the next minute. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ true, base, /* now= */ base - 3 * MINUTE_IN_MILLIS - 2 * SECOND_IN_MILLIS - 100, /* expectedDelay= */ 2 * SECOND_IN_MILLIS + 100); // Adaptive timer, barely a few ms more than than 3 minutes remaining -> on the next minute. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ true, base, /* now= */ base - 3 * MINUTE_IN_MILLIS - 1, /* expectedDelay= */ 1); // Adaptive timer, less than than 3 minutes remaining -> on the next second. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ true, base, /* now= */ base - 2 * MINUTE_IN_MILLIS - 8 * SECOND_IN_MILLIS - 400, /* expectedDelay= */ 400); } private void verifyNextTickScheduledIn(boolean adaptive, boolean countdown, long base, long now, long expectedDelay) { AtomicLong elapsedRealtime = new AtomicLong(0); // Need to spy() because it's not possible to replace the looper used by postDelayed() :( Chronometer chronometer = spy( new Chronometer(mActivity, () -> elapsedRealtime.get(), () -> Instant.ofEpochMilli(0), null, 0, 0)); mActivity.setContentView(chronometer); elapsedRealtime.set(now); chronometer.setCountDown(countdown); chronometer.setBase(base); chronometer.setUseAdaptiveFormat(adaptive); chronometer.start(); // Chronometer adds a small delay to prevent transitions *exactly* on the time, but for // testing it's better to hide this. verify(chronometer).postDelayed(any(), eq(expectedDelay + 3)); } private void testChronometerTicks( Instant clockSystemNow, List<String> expectedTicks, Loading Loading
core/java/android/widget/Chronometer.java +28 −8 Original line number Diff line number Diff line Loading @@ -16,6 +16,9 @@ package android.widget; import static android.text.format.DateUtils.MINUTE_IN_MILLIS; import static android.text.format.DateUtils.SECOND_IN_MILLIS; import static java.util.Objects.requireNonNull; import android.annotation.ElapsedRealtimeLong; Loading Loading @@ -70,6 +73,13 @@ import java.util.function.LongSupplier; public class Chronometer extends TextView { private static final String TAG = "Chronometer"; /** * In adaptive format, when displaying an elapsed/remaining duration greater than or equal to * this number of minutes, seconds will not be shown (which also means the chronometer will tick * on the minute instead of on the second). */ private static final int ADAPTIVE_MINUTES_WITHOUT_SECONDS = 3; /** * A callback that notifies when the chronometer has incremented on its own. */ Loading Loading @@ -433,7 +443,7 @@ public class Chronometer extends TextView { } } else if (minutes.getNumber().intValue() != 0) { partsList.add(minutes); if (minutes.getNumber().intValue() < 3) { if (minutes.getNumber().intValue() < ADAPTIVE_MINUTES_WITHOUT_SECONDS) { partsList.add(seconds); } } Loading Loading @@ -467,7 +477,7 @@ public class Chronometer extends TextView { if (running) { updateText(mElapsedRealtimeClock.getAsLong()); dispatchChronometerTick(); postTickOnNextSecond(); postTickOnNextChange(); } else { removeCallbacks(mTickRunnable); } Loading @@ -481,22 +491,32 @@ public class Chronometer extends TextView { if (mRunning) { updateText(mElapsedRealtimeClock.getAsLong()); dispatchChronometerTick(); postTickOnNextSecond(); postTickOnNextChange(); } } }; private void postTickOnNextSecond() { private void postTickOnNextChange() { long nowMillis = mNow; // In adaptive format, ticks are every 1 minute instead of 1 second, if the time elapsed // or remaining is >= 3 minutes. Thus for time > 3 minutes the tick will be "on the minute" // and for lower than that it's "on the second". long periodInMillis = mUseAdaptiveFormat && Math.abs(nowMillis - mBase) > ADAPTIVE_MINUTES_WITHOUT_SECONDS * MINUTE_IN_MILLIS ? MINUTE_IN_MILLIS : SECOND_IN_MILLIS; long delayMillis; if (mCountDown) { delayMillis = (mBase - nowMillis) % 1000; delayMillis = (mBase - nowMillis) % periodInMillis; if (delayMillis <= 0) { delayMillis += 1000; delayMillis += periodInMillis; } } else { delayMillis = 1000 - (Math.abs(nowMillis - mBase) % 1000); delayMillis = periodInMillis - (Math.abs(nowMillis - mBase) % periodInMillis); } // Aim for 3 milliseconds into the next second so we don't update exactly on the second delayMillis += 3; postDelayed(mTickRunnable, delayMillis); Loading @@ -511,7 +531,7 @@ public class Chronometer extends TextView { private static final int MIN_IN_SEC = 60; private static final int HOUR_IN_SEC = MIN_IN_SEC*60; private static String formatDuration(long ms) { int duration = (int) (ms / DateUtils.SECOND_IN_MILLIS); int duration = (int) (ms / SECOND_IN_MILLIS); if (duration < 0) { duration = -duration; } Loading
core/tests/coretests/src/android/widget/ChronometerTest.java +91 −0 Original line number Diff line number Diff line Loading @@ -17,9 +17,16 @@ package android.widget; import static android.text.format.DateUtils.MINUTE_IN_MILLIS; import static android.text.format.DateUtils.SECOND_IN_MILLIS; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertArrayEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static java.time.temporal.ChronoUnit.MINUTES; import static java.time.temporal.ChronoUnit.SECONDS; Loading @@ -41,6 +48,7 @@ import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; /** Loading Loading @@ -307,6 +315,89 @@ public class ChronometerTest extends ActivityInstrumentationTestCase2<Chronomete assertThat(chronometer.getText().toString()).isEqualTo("1m 0s"); } @UiThreadTest public void testScheduledTicks() { long base = SystemClock.elapsedRealtime(); // Non-adaptive: Always on the next second, regardless of stopwatch or countdown. verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ false, base, /* now= */ base + 200, /* expectedDelay= */ 800); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ false, base, /* now= */ base + 5 * SECOND_IN_MILLIS, /* expectedDelay= */ 1000); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ false, base, /* now= */ base + 30 * SECOND_IN_MILLIS + 300, /* expectedDelay= */ 700); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ false, base, /* now= */ base + 45 * MINUTE_IN_MILLIS + 900, /* expectedDelay= */ 100); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ true, base, /* now= */ base - 200, /* expectedDelay= */ 200); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ true, base, /* now= */ base - 10 * SECOND_IN_MILLIS, /* expectedDelay= */ 1000); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ true, base, /* now= */ base + 200, // overrun countdown /* expectedDelay= */ 800); verifyNextTickScheduledIn(/* adaptive= */ false, /* countdown= */ true, base, /* now= */ base + 4 * SECOND_IN_MILLIS + 300, // overrun countdown /* expectedDelay= */ 700); // Adaptive stopwatch, 2:20 elapsed (< 3 minutes) -> on the second. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ false, base, /* now= */ base + 2 * MINUTE_IN_MILLIS + 20 * SECOND_IN_MILLIS + 100, /* expectedDelay= */ 900); // Adaptive stopwatch, more than than 3 minutes elapsed -> on the next minute. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ false, base, /* now= */ base + 5 * MINUTE_IN_MILLIS + 10 * SECOND_IN_MILLIS + 300, /* expectedDelay= */ 49 * SECOND_IN_MILLIS + 700); // Adaptive timer, more than than 3 minutes remaining -> on the next minute. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ true, base, /* now= */ base - 4 * MINUTE_IN_MILLIS - 10 * SECOND_IN_MILLIS - 300, /* expectedDelay= */ 10 * SECOND_IN_MILLIS + 300); // Adaptive timer, slightly more than than 3 minutes remaining -> on the next minute. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ true, base, /* now= */ base - 3 * MINUTE_IN_MILLIS - 2 * SECOND_IN_MILLIS - 100, /* expectedDelay= */ 2 * SECOND_IN_MILLIS + 100); // Adaptive timer, barely a few ms more than than 3 minutes remaining -> on the next minute. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ true, base, /* now= */ base - 3 * MINUTE_IN_MILLIS - 1, /* expectedDelay= */ 1); // Adaptive timer, less than than 3 minutes remaining -> on the next second. verifyNextTickScheduledIn(/* adaptive= */ true, /* countdown= */ true, base, /* now= */ base - 2 * MINUTE_IN_MILLIS - 8 * SECOND_IN_MILLIS - 400, /* expectedDelay= */ 400); } private void verifyNextTickScheduledIn(boolean adaptive, boolean countdown, long base, long now, long expectedDelay) { AtomicLong elapsedRealtime = new AtomicLong(0); // Need to spy() because it's not possible to replace the looper used by postDelayed() :( Chronometer chronometer = spy( new Chronometer(mActivity, () -> elapsedRealtime.get(), () -> Instant.ofEpochMilli(0), null, 0, 0)); mActivity.setContentView(chronometer); elapsedRealtime.set(now); chronometer.setCountDown(countdown); chronometer.setBase(base); chronometer.setUseAdaptiveFormat(adaptive); chronometer.start(); // Chronometer adds a small delay to prevent transitions *exactly* on the time, but for // testing it's better to hide this. verify(chronometer).postDelayed(any(), eq(expectedDelay + 3)); } private void testChronometerTicks( Instant clockSystemNow, List<String> expectedTicks, Loading