Merge "AOSP/DeskClock - Add Kotlin for FormattedStringModel, PeriodicCallbackModel"
diff --git a/Android.bp b/Android.bp
index 1406d4d..68ec666 100644
--- a/Android.bp
+++ b/Android.bp
@@ -53,6 +53,8 @@
         "src/**/deskclock/settings/*.java",
         "src/**/deskclock/stopwatch/*.java",
         "src/**/deskclock/timer/*.java",
+        "src/**/deskclock/uidata/FormattedStringModel.java",
+        "src/**/deskclock/uidata/PeriodicCallbackModel.java",
     ],
     product_specific: true,
     static_libs: [
diff --git a/src/com/android/deskclock/AlarmClockFragment.java b/src/com/android/deskclock/AlarmClockFragment.java
index 5c02d03..fb2078f 100644
--- a/src/com/android/deskclock/AlarmClockFragment.java
+++ b/src/com/android/deskclock/AlarmClockFragment.java
@@ -196,7 +196,7 @@
 
         // Schedule a runnable to update the "Today/Tomorrow" values displayed for non-repeating
         // alarms when midnight passes.
-        UiDataModel.getUiDataModel().addMidnightCallback(mMidnightUpdater, 100);
+        UiDataModel.getUiDataModel().addMidnightCallback(mMidnightUpdater);
 
         // Check if another app asked us to create a blank new alarm.
         final Intent intent = getActivity().getIntent();
diff --git a/src/com/android/deskclock/ClockFragment.java b/src/com/android/deskclock/ClockFragment.java
index db6cbcf..4bc43d4 100644
--- a/src/com/android/deskclock/ClockFragment.java
+++ b/src/com/android/deskclock/ClockFragment.java
@@ -135,7 +135,7 @@
         }
 
         // Schedule a runnable to update the date every quarter hour.
-        UiDataModel.getUiDataModel().addQuarterHourCallback(mQuarterHourUpdater, 100);
+        UiDataModel.getUiDataModel().addQuarterHourCallback(mQuarterHourUpdater);
 
         return fragmentView;
     }
diff --git a/src/com/android/deskclock/Screensaver.java b/src/com/android/deskclock/Screensaver.java
index 427885e..52b8342 100644
--- a/src/com/android/deskclock/Screensaver.java
+++ b/src/com/android/deskclock/Screensaver.java
@@ -132,7 +132,7 @@
         Utils.refreshAlarm(this, mContentView);
 
         startPositionUpdater();
-        UiDataModel.getUiDataModel().addMidnightCallback(mMidnightUpdater, 100);
+        UiDataModel.getUiDataModel().addMidnightCallback(mMidnightUpdater);
     }
 
     @Override
diff --git a/src/com/android/deskclock/ScreensaverActivity.java b/src/com/android/deskclock/ScreensaverActivity.java
index 656cfc7..49c6c42 100644
--- a/src/com/android/deskclock/ScreensaverActivity.java
+++ b/src/com/android/deskclock/ScreensaverActivity.java
@@ -163,7 +163,7 @@
         Utils.refreshAlarm(ScreensaverActivity.this, mContentView);
 
         startPositionUpdater();
-        UiDataModel.getUiDataModel().addMidnightCallback(mMidnightUpdater, 100);
+        UiDataModel.getUiDataModel().addMidnightCallback(mMidnightUpdater);
 
         final Intent intent = registerReceiver(null, new IntentFilter(ACTION_BATTERY_CHANGED));
         final boolean pluggedIn = intent != null && intent.getIntExtra(EXTRA_PLUGGED, 0) != 0;
diff --git a/src/com/android/deskclock/uidata/FormattedStringModel.kt b/src/com/android/deskclock/uidata/FormattedStringModel.kt
new file mode 100644
index 0000000..f720299
--- /dev/null
+++ b/src/com/android/deskclock/uidata/FormattedStringModel.kt
@@ -0,0 +1,189 @@
+/*
+ * Copyright (C) 2020 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.deskclock.uidata
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.util.ArrayMap
+import android.util.SparseArray
+
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.GregorianCalendar
+import java.util.Locale
+
+/**
+ * All formatted strings that are cached for performance are accessed via this model.
+ */
+internal class FormattedStringModel(context: Context) {
+    /** Clears data structures containing data that is locale-sensitive.  */
+    private val mLocaleChangedReceiver: BroadcastReceiver = LocaleChangedReceiver()
+
+    /**
+     * Caches formatted numbers in the current locale padded with zeroes to requested lengths.
+     * The first level of the cache maps length to the second level of the cache.
+     * The second level of the cache maps an integer to a formatted String in the current locale.
+     */
+    private val mNumberFormatCache = SparseArray<SparseArray<String>>(3)
+
+    /** Single-character version of weekday names; e.g.: 'S', 'M', 'T', 'W', 'T', 'F', 'S'  */
+    private var mShortWeekdayNames: MutableMap<Int, String>? = null
+
+    /** Full weekday names; e.g.: 'Sunday', 'Monday', 'Tuesday', etc.  */
+    private var mLongWeekdayNames: MutableMap<Int, String>? = null
+
+    init {
+        // Clear caches affected by locale when locale changes.
+        val localeBroadcastFilter = IntentFilter(Intent.ACTION_LOCALE_CHANGED)
+        context.registerReceiver(mLocaleChangedReceiver, localeBroadcastFilter)
+    }
+
+    /**
+     * This method is intended to be used when formatting numbers occurs in a hotspot such as the
+     * update loop of a timer or stopwatch. It returns cached results when possible in order to
+     * provide speed and limit garbage to be collected by the virtual machine.
+     *
+     * @param value a positive integer to format as a String
+     * @return the `value` formatted as a String in the current locale
+     * @throws IllegalArgumentException if `value` is negative
+     */
+    fun getFormattedNumber(value: Int): String {
+        val length = if (value == 0) 1 else Math.log10(value.toDouble()).toInt() + 1
+        return getFormattedNumber(false, value, length)
+    }
+
+    /**
+     * This method is intended to be used when formatting numbers occurs in a hotspot such as the
+     * update loop of a timer or stopwatch. It returns cached results when possible in order to
+     * provide speed and limit garbage to be collected by the virtual machine.
+     *
+     * @param value a positive integer to format as a String
+     * @param length the length of the String; zeroes are padded to match this length
+     * @return the `value` formatted as a String in the current locale and padded to the
+     * requested `length`
+     * @throws IllegalArgumentException if `value` is negative
+     */
+    fun getFormattedNumber(value: Int, length: Int): String {
+        return getFormattedNumber(false, value, length)
+    }
+
+    /**
+     * This method is intended to be used when formatting numbers occurs in a hotspot such as the
+     * update loop of a timer or stopwatch. It returns cached results when possible in order to
+     * provide speed and limit garbage to be collected by the virtual machine.
+     *
+     * @param negative force a minus sign (-) onto the display, even if `value` is `0`
+     * @param value a positive integer to format as a String
+     * @param length the length of the String; zeroes are padded to match this length. If
+     * `negative` is `true` the return value will contain a minus sign and a total
+     * length of `length + 1`.
+     * @return the `value` formatted as a String in the current locale and padded to the
+     * requested `length`
+     * @throws IllegalArgumentException if `value` is negative
+     */
+    fun getFormattedNumber(negative: Boolean, value: Int, length: Int): String {
+        require(value >= 0) { "value may not be negative: $value" }
+
+        // Look up the value cache using the length; -ve and +ve values are cached separately.
+        val lengthCacheKey = if (negative) -length else length
+        var valueCache = mNumberFormatCache[lengthCacheKey]
+        if (valueCache == null) {
+            valueCache = SparseArray(Math.pow(10.0, length.toDouble()).toInt())
+            mNumberFormatCache.put(lengthCacheKey, valueCache)
+        }
+
+        // Look up the cached formatted value using the value.
+        var formatted = valueCache[value]
+        if (formatted == null) {
+            val sign = if (negative) "−" else ""
+            formatted = String.format(Locale.getDefault(), sign + "%0" + length + "d", value)
+            valueCache.put(value, formatted)
+        }
+
+        return formatted
+    }
+
+    /**
+     * @param calendarDay any of the following values
+     *
+     *  * [Calendar.SUNDAY]
+     *  * [Calendar.MONDAY]
+     *  * [Calendar.TUESDAY]
+     *  * [Calendar.WEDNESDAY]
+     *  * [Calendar.THURSDAY]
+     *  * [Calendar.FRIDAY]
+     *  * [Calendar.SATURDAY]
+     *
+     * @return single-character weekday name; e.g.: 'S', 'M', 'T', 'W', 'T', 'F', 'S'
+     */
+    fun getShortWeekday(calendarDay: Int): String? {
+        if (mShortWeekdayNames == null) {
+            mShortWeekdayNames = ArrayMap(7)
+
+            val format = SimpleDateFormat("ccccc", Locale.getDefault())
+            for (i in Calendar.SUNDAY..Calendar.SATURDAY) {
+                val calendar: Calendar = GregorianCalendar(2014, Calendar.JULY, 20 + i - 1)
+                val weekday = format.format(calendar.time)
+                mShortWeekdayNames!![i] = weekday
+            }
+        }
+
+        return mShortWeekdayNames!![calendarDay]
+    }
+
+    /**
+     * @param calendarDay any of the following values
+     *
+     *  * [Calendar.SUNDAY]
+     *  * [Calendar.MONDAY]
+     *  * [Calendar.TUESDAY]
+     *  * [Calendar.WEDNESDAY]
+     *  * [Calendar.THURSDAY]
+     *  * [Calendar.FRIDAY]
+     *  * [Calendar.SATURDAY]
+     *
+     * @return full weekday name; e.g.: 'Sunday', 'Monday', 'Tuesday', etc.
+     */
+    fun getLongWeekday(calendarDay: Int): String? {
+        if (mLongWeekdayNames == null) {
+            mLongWeekdayNames = ArrayMap(7)
+
+            val calendar: Calendar = GregorianCalendar(2014, Calendar.JULY, 20)
+            val format = SimpleDateFormat("EEEE", Locale.getDefault())
+            for (i in Calendar.SUNDAY..Calendar.SATURDAY) {
+                val weekday = format.format(calendar.time)
+                mLongWeekdayNames!![i] = weekday
+                calendar.add(Calendar.DAY_OF_YEAR, 1)
+            }
+        }
+
+        return mLongWeekdayNames!![calendarDay]
+    }
+
+    /**
+     * Cached information that is locale-sensitive must be cleared in response to locale changes.
+     */
+    private inner class LocaleChangedReceiver : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            mNumberFormatCache.clear()
+            mShortWeekdayNames = null
+            mLongWeekdayNames = null
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/deskclock/uidata/PeriodicCallbackModel.java b/src/com/android/deskclock/uidata/PeriodicCallbackModel.java
index 4b522d1..b139e7f 100644
--- a/src/com/android/deskclock/uidata/PeriodicCallbackModel.java
+++ b/src/com/android/deskclock/uidata/PeriodicCallbackModel.java
@@ -81,26 +81,29 @@
 
     /**
      * @param runnable to be called every quarter-hour
-     * @param offset an offset applied to the quarter-hour to control when the callback occurs
      */
-    void addQuarterHourCallback(Runnable runnable, long offset) {
-        addPeriodicCallback(runnable, Period.QUARTER_HOUR, offset);
+    void addQuarterHourCallback(Runnable runnable) {
+        // Callbacks *can* occur early so pad in an extra 100ms on the quarter-hour callback
+        // to ensure the sampled wallclock time reflects the subsequent quarter-hour.
+        addPeriodicCallback(runnable, Period.QUARTER_HOUR, 100L);
     }
 
     /**
      * @param runnable to be called every hour
-     * @param offset an offset applied to the hour to control when the callback occurs
      */
-    void addHourCallback(Runnable runnable, long offset) {
-        addPeriodicCallback(runnable, Period.HOUR, offset);
+    void addHourCallback(Runnable runnable) {
+        // Callbacks *can* occur early so pad in an extra 100ms on the hour callback to ensure
+        // the sampled wallclock time reflects the subsequent hour.
+        addPeriodicCallback(runnable, Period.HOUR, 100L);
     }
 
     /**
      * @param runnable to be called every midnight
-     * @param offset an offset applied to the midnight to control when the callback occurs
      */
-    void addMidnightCallback(Runnable runnable, long offset) {
-        addPeriodicCallback(runnable, Period.MIDNIGHT, offset);
+    void addMidnightCallback(Runnable runnable) {
+        // Callbacks *can* occur early so pad in an extra 100ms on the midnight callback to ensure
+        // the sampled wallclock time reflects the subsequent day.
+        addPeriodicCallback(runnable, Period.MIDNIGHT, 100L);
     }
 
     /**
diff --git a/src/com/android/deskclock/uidata/PeriodicCallbackModel.kt b/src/com/android/deskclock/uidata/PeriodicCallbackModel.kt
new file mode 100644
index 0000000..224344e
--- /dev/null
+++ b/src/com/android/deskclock/uidata/PeriodicCallbackModel.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2020 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.deskclock.uidata
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Handler
+import android.text.format.DateUtils
+import androidx.annotation.VisibleForTesting
+
+import com.android.deskclock.LogUtils
+import com.android.deskclock.Utils
+
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.Calendar
+
+/**
+ * All callbacks to be delivered at requested times on the main thread if the application is in the
+ * foreground when the callback time passes.
+ */
+internal class PeriodicCallbackModel(context: Context) {
+
+    @VisibleForTesting
+    internal enum class Period {
+        MINUTE, QUARTER_HOUR, HOUR, MIDNIGHT
+    }
+
+    /** Reschedules callbacks when the device time changes.  */
+    private val mTimeChangedReceiver: BroadcastReceiver = TimeChangedReceiver()
+
+    private val mPeriodicRunnables: MutableList<PeriodicRunnable> = CopyOnWriteArrayList()
+
+    init {
+        // Reschedules callbacks when the device time changes.
+        val timeChangedBroadcastFilter = IntentFilter()
+        timeChangedBroadcastFilter.addAction(Intent.ACTION_TIME_CHANGED)
+        timeChangedBroadcastFilter.addAction(Intent.ACTION_DATE_CHANGED)
+        timeChangedBroadcastFilter.addAction(Intent.ACTION_TIMEZONE_CHANGED)
+        context.registerReceiver(mTimeChangedReceiver, timeChangedBroadcastFilter)
+    }
+
+    /**
+     * @param runnable to be called every minute
+     * @param offset an offset applied to the minute to control when the callback occurs
+     */
+    fun addMinuteCallback(runnable: Runnable, offset: Long) {
+        addPeriodicCallback(runnable, Period.MINUTE, offset)
+    }
+
+    /**
+     * @param runnable to be called every quarter-hour
+     */
+    fun addQuarterHourCallback(runnable: Runnable) {
+        // Callbacks *can* occur early so pad in an extra 100ms on the quarter-hour callback
+        // to ensure the sampled wallclock time reflects the subsequent quarter-hour.
+        addPeriodicCallback(runnable, Period.QUARTER_HOUR, 100L)
+    }
+
+    /**
+     * @param runnable to be called every hour
+     */
+    fun addHourCallback(runnable: Runnable) {
+        // Callbacks *can* occur early so pad in an extra 100ms on the hour callback to ensure
+        // the sampled wallclock time reflects the subsequent hour.
+        addPeriodicCallback(runnable, Period.HOUR, 100L)
+    }
+
+    /**
+     * @param runnable to be called every midnight
+     */
+    fun addMidnightCallback(runnable: Runnable) {
+        // Callbacks *can* occur early so pad in an extra 100ms on the midnight callback to ensure
+        // the sampled wallclock time reflects the subsequent day.
+        addPeriodicCallback(runnable, Period.MIDNIGHT, 100L)
+    }
+
+    /**
+     * @param runnable to be called periodically
+     */
+    private fun addPeriodicCallback(runnable: Runnable, period: Period, offset: Long) {
+        val periodicRunnable = PeriodicRunnable(runnable, period, offset)
+        mPeriodicRunnables.add(periodicRunnable)
+        periodicRunnable.schedule()
+    }
+
+    /**
+     * @param runnable to no longer be called periodically
+     */
+    fun removePeriodicCallback(runnable: Runnable) {
+        for (periodicRunnable in mPeriodicRunnables) {
+            if (periodicRunnable.mDelegate === runnable) {
+                periodicRunnable.unSchedule()
+                mPeriodicRunnables.remove(periodicRunnable)
+                return
+            }
+        }
+    }
+
+    /**
+     * Schedules the execution of the given delegate Runnable at the next callback time.
+     */
+    private class PeriodicRunnable(
+        val mDelegate: Runnable,
+        private val mPeriod: Period,
+        private val mOffset: Long
+    ) : Runnable {
+        override fun run() {
+            LOGGER.i("Executing periodic callback for %s because the period ended", mPeriod)
+            mDelegate.run()
+            schedule()
+        }
+
+        fun runAndReschedule() {
+            LOGGER.i("Executing periodic callback for %s because the time changed", mPeriod)
+            unSchedule()
+            mDelegate.run()
+            schedule()
+        }
+
+        fun schedule() {
+            val delay = getDelay(System.currentTimeMillis(), mPeriod, mOffset)
+            handler.postDelayed(this, delay)
+        }
+
+        fun unSchedule() {
+            handler.removeCallbacks(this)
+        }
+    }
+
+    /**
+     * Reschedules callbacks when the device time changes.
+     */
+    private inner class TimeChangedReceiver : BroadcastReceiver() {
+        override fun onReceive(context: Context, intent: Intent) {
+            for (periodicRunnable in mPeriodicRunnables) {
+                periodicRunnable.runAndReschedule()
+            }
+        }
+    }
+
+    companion object {
+        private val LOGGER = LogUtils.Logger("Periodic")
+
+        private const val QUARTER_HOUR_IN_MILLIS = 15 * DateUtils.MINUTE_IN_MILLIS
+
+        private var sHandler: Handler? = null
+
+        /**
+         * Return the delay until the given `period` elapses adjusted by the given `offset`.
+         *
+         * @param now the current time
+         * @param period the frequency with which callbacks should be given
+         * @param offset an offset to add to the normal period; allows the callback to
+         * be made relative to the normally scheduled period end
+         * @return the time delay from `now` to schedule the callback
+         */
+        @VisibleForTesting
+        @JvmStatic
+        fun getDelay(now: Long, period: Period, offset: Long): Long {
+            val periodStart = now - offset
+
+            return when (period) {
+                Period.MINUTE -> {
+                    val lastMinute = periodStart - periodStart % DateUtils.MINUTE_IN_MILLIS
+                    val nextMinute = lastMinute + DateUtils.MINUTE_IN_MILLIS
+                    nextMinute - now + offset
+                }
+                Period.QUARTER_HOUR -> {
+                    val lastQuarterHour = periodStart - periodStart % QUARTER_HOUR_IN_MILLIS
+                    val nextQuarterHour = lastQuarterHour + QUARTER_HOUR_IN_MILLIS
+                    nextQuarterHour - now + offset
+                }
+                Period.HOUR -> {
+                    val lastHour = periodStart - periodStart % DateUtils.HOUR_IN_MILLIS
+                    val nextHour = lastHour + DateUtils.HOUR_IN_MILLIS
+                    nextHour - now + offset
+                }
+                Period.MIDNIGHT -> {
+                    val nextMidnight = Calendar.getInstance()
+                    nextMidnight.timeInMillis = periodStart
+                    nextMidnight.add(Calendar.DATE, 1)
+                    nextMidnight[Calendar.HOUR_OF_DAY] = 0
+                    nextMidnight[Calendar.MINUTE] = 0
+                    nextMidnight[Calendar.SECOND] = 0
+                    nextMidnight[Calendar.MILLISECOND] = 0
+                    nextMidnight.timeInMillis - now + offset
+                }
+            }
+        }
+
+        // TODO(b/157255731) Replace deprecated Handler call here
+        private val handler: Handler
+            get() {
+                Utils.enforceMainLooper()
+                if (sHandler == null) {
+                    sHandler = Handler()
+                }
+                return sHandler!!
+            }
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/deskclock/uidata/UiDataModel.java b/src/com/android/deskclock/uidata/UiDataModel.java
index f7a6917..eb072bd 100644
--- a/src/com/android/deskclock/uidata/UiDataModel.java
+++ b/src/com/android/deskclock/uidata/UiDataModel.java
@@ -338,29 +338,26 @@
 
     /**
      * @param runnable to be called every quarter-hour
-     * @param offset an offset applied to the quarter-hour to control when the callback occurs
      */
-    public void addQuarterHourCallback(Runnable runnable, long offset) {
+    public void addQuarterHourCallback(Runnable runnable) {
         enforceMainLooper();
-        mPeriodicCallbackModel.addQuarterHourCallback(runnable, offset);
+        mPeriodicCallbackModel.addQuarterHourCallback(runnable);
     }
 
     /**
      * @param runnable to be called every hour
-     * @param offset an offset applied to the hour to control when the callback occurs
      */
-    public void addHourCallback(Runnable runnable, long offset) {
+    public void addHourCallback(Runnable runnable) {
         enforceMainLooper();
-        mPeriodicCallbackModel.addHourCallback(runnable, offset);
+        mPeriodicCallbackModel.addHourCallback(runnable);
     }
 
     /**
      * @param runnable to be called every midnight
-     * @param offset an offset applied to the midnight to control when the callback occurs
      */
-    public void addMidnightCallback(Runnable runnable, long offset) {
+    public void addMidnightCallback(Runnable runnable) {
         enforceMainLooper();
-        mPeriodicCallbackModel.addMidnightCallback(runnable, offset);
+        mPeriodicCallbackModel.addMidnightCallback(runnable);
     }
 
     /**
diff --git a/tests/src/com/android/deskclock/uidata/FormattedStringModelTest.java b/tests/src/com/android/deskclock/uidata/FormattedStringModelTest.java
new file mode 100644
index 0000000..9893545
--- /dev/null
+++ b/tests/src/com/android/deskclock/uidata/FormattedStringModelTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2020 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.deskclock.uidata;
+
+import android.content.Context;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.assertEquals;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class FormattedStringModelTest {
+
+    private FormattedStringModel model;
+
+    @Before
+    public void setUp() {
+        Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        model = new FormattedStringModel(context);
+    }
+
+    @After
+    public void tearDown() {
+        model = null;
+    }
+
+    @Test
+    public void positiveFormattedNumberWithNoPadding() {
+        assertEquals("0", model.getFormattedNumber(0));
+        assertEquals("9", model.getFormattedNumber(9));
+        assertEquals("10", model.getFormattedNumber(10));
+        assertEquals("99", model.getFormattedNumber(99));
+        assertEquals("100", model.getFormattedNumber(100));
+    }
+
+    @Test
+    public void positiveFormattedNumber() {
+        assertEquals("0", model.getFormattedNumber(false, 0, 1));
+        assertEquals("00", model.getFormattedNumber(false, 0, 2));
+        assertEquals("000", model.getFormattedNumber(false, 0, 3));
+
+        assertEquals("9", model.getFormattedNumber(false, 9, 1));
+        assertEquals("09", model.getFormattedNumber(false, 9, 2));
+        assertEquals("009", model.getFormattedNumber(false, 9, 3));
+
+        assertEquals("90", model.getFormattedNumber(false, 90, 2));
+        assertEquals("090", model.getFormattedNumber(false, 90, 3));
+
+        assertEquals("999", model.getFormattedNumber(false, 999, 3));
+    }
+
+    @Test
+    public void negativeFormattedNumber() {
+        assertEquals("−0", model.getFormattedNumber(true, 0, 1));
+        assertEquals("−00", model.getFormattedNumber(true, 0, 2));
+        assertEquals("−000", model.getFormattedNumber(true, 0, 3));
+
+        assertEquals("−9", model.getFormattedNumber(true, 9, 1));
+        assertEquals("−09", model.getFormattedNumber(true, 9, 2));
+        assertEquals("−009", model.getFormattedNumber(true, 9, 3));
+
+        assertEquals("−90", model.getFormattedNumber(true, 90, 2));
+        assertEquals("−090", model.getFormattedNumber(true, 90, 3));
+
+        assertEquals("−999", model.getFormattedNumber(true, 999, 3));
+    }
+}
diff --git a/tests/src/com/android/deskclock/uidata/PeriodicCallbackModelTest.java b/tests/src/com/android/deskclock/uidata/PeriodicCallbackModelTest.java
new file mode 100644
index 0000000..3df7404
--- /dev/null
+++ b/tests/src/com/android/deskclock/uidata/PeriodicCallbackModelTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2020 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.deskclock.uidata;
+
+import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Calendar;
+
+import static com.android.deskclock.uidata.PeriodicCallbackModel.Period.HOUR;
+import static com.android.deskclock.uidata.PeriodicCallbackModel.Period.MIDNIGHT;
+import static com.android.deskclock.uidata.PeriodicCallbackModel.Period.MINUTE;
+import static com.android.deskclock.uidata.PeriodicCallbackModel.Period.QUARTER_HOUR;
+
+import static java.util.Calendar.MILLISECOND;
+import static org.junit.Assert.assertEquals;
+
+@RunWith(AndroidJUnit4ClassRunner.class)
+public class PeriodicCallbackModelTest {
+
+    @Test
+    public void getMinuteDelay() {
+        assertEquals(1, PeriodicCallbackModel.getDelay(56999, MINUTE, -3000));
+        assertEquals(60000, PeriodicCallbackModel.getDelay(57000, MINUTE, -3000));
+        assertEquals(59999, PeriodicCallbackModel.getDelay(57001, MINUTE, -3000));
+
+        assertEquals(1, PeriodicCallbackModel.getDelay(59999, MINUTE, 0));
+        assertEquals(60000, PeriodicCallbackModel.getDelay(60000, MINUTE, 0));
+        assertEquals(59999, PeriodicCallbackModel.getDelay(60001, MINUTE, 0));
+
+        assertEquals(3001, PeriodicCallbackModel.getDelay(59999, MINUTE, 3000));
+        assertEquals(3000, PeriodicCallbackModel.getDelay(60000, MINUTE, 3000));
+        assertEquals(1, PeriodicCallbackModel.getDelay(62999, MINUTE, 3000));
+        assertEquals(60000, PeriodicCallbackModel.getDelay(63000, MINUTE, 3000));
+        assertEquals(59999, PeriodicCallbackModel.getDelay(63001, MINUTE, 3000));
+    }
+
+    @Test
+    public void getQuarterHourDelay() {
+        assertEquals(1, PeriodicCallbackModel.getDelay(896999, QUARTER_HOUR, -3000));
+        assertEquals(900000, PeriodicCallbackModel.getDelay(897000, QUARTER_HOUR, -3000));
+        assertEquals(899999, PeriodicCallbackModel.getDelay(897001, QUARTER_HOUR, -3000));
+
+        assertEquals(1, PeriodicCallbackModel.getDelay(899999, QUARTER_HOUR, 0));
+        assertEquals(900000, PeriodicCallbackModel.getDelay(900000, QUARTER_HOUR, 0));
+        assertEquals(899999, PeriodicCallbackModel.getDelay(900001, QUARTER_HOUR, 0));
+
+        assertEquals(3001, PeriodicCallbackModel.getDelay(899999, QUARTER_HOUR, 3000));
+        assertEquals(3000, PeriodicCallbackModel.getDelay(900000, QUARTER_HOUR, 3000));
+        assertEquals(1, PeriodicCallbackModel.getDelay(902999, QUARTER_HOUR, 3000));
+        assertEquals(900000, PeriodicCallbackModel.getDelay(903000, QUARTER_HOUR, 3000));
+        assertEquals(899999, PeriodicCallbackModel.getDelay(903001, QUARTER_HOUR, 3000));
+    }
+
+    @Test
+    public void getHourDelay() {
+        assertEquals(1, PeriodicCallbackModel.getDelay(3596999, HOUR, -3000));
+        assertEquals(3600000, PeriodicCallbackModel.getDelay(3597000, HOUR, -3000));
+        assertEquals(3599999, PeriodicCallbackModel.getDelay(3597001, HOUR, -3000));
+
+        assertEquals(1, PeriodicCallbackModel.getDelay(3599999, HOUR, 0));
+        assertEquals(3600000, PeriodicCallbackModel.getDelay(3600000, HOUR, 0));
+        assertEquals(3599999, PeriodicCallbackModel.getDelay(3600001, HOUR, 0));
+
+        assertEquals(3001, PeriodicCallbackModel.getDelay(3599999, HOUR, 3000));
+        assertEquals(3000, PeriodicCallbackModel.getDelay(3600000, HOUR, 3000));
+        assertEquals(1, PeriodicCallbackModel.getDelay(3602999, HOUR, 3000));
+        assertEquals(3600000, PeriodicCallbackModel.getDelay(3603000, HOUR, 3000));
+        assertEquals(3599999, PeriodicCallbackModel.getDelay(3603001, HOUR, 3000));
+    }
+
+    @Test
+    public void getMidnightDelay() {
+        final Calendar c = Calendar.getInstance();
+        c.set(2016, 0, 20, 0, 0, 0);
+        c.set(MILLISECOND, 0);
+        final long now = c.getTimeInMillis();
+
+        assertEquals(1, PeriodicCallbackModel.getDelay(now - 3001, MIDNIGHT, -3000));
+        assertEquals(86400000, PeriodicCallbackModel.getDelay(now - 3000, MIDNIGHT, -3000));
+        assertEquals(86399999, PeriodicCallbackModel.getDelay(now - 2999, MIDNIGHT, -3000));
+
+        assertEquals(1, PeriodicCallbackModel.getDelay(now - 1, MIDNIGHT, 0));
+        assertEquals(86400000, PeriodicCallbackModel.getDelay(now, MIDNIGHT, 0));
+        assertEquals(86399999, PeriodicCallbackModel.getDelay(now + 1, MIDNIGHT, 0));
+
+        assertEquals(3001, PeriodicCallbackModel.getDelay(now - 1, MIDNIGHT, 3000));
+        assertEquals(3000, PeriodicCallbackModel.getDelay(now, MIDNIGHT, 3000));
+        assertEquals(1, PeriodicCallbackModel.getDelay(now + 2999, MIDNIGHT, 3000));
+        assertEquals(86400000, PeriodicCallbackModel.getDelay(now + 3000, MIDNIGHT, 3000));
+        assertEquals(86399999, PeriodicCallbackModel.getDelay(now + 3001, MIDNIGHT, 3000));
+    }
+}