AOSP/DeskClock - Add Kotlin files for Stopwatch data files

Test: manual - Ran the following on Sargo phone. Tested the stopwatch
interactions (ie. start, pause, reset, laps, share).

$ source build/envsetup.sh
$ lunch aosp_sargo-userdebug
$ make DeskClockKotlin -j
$ adb install out/target/product/sargo/product/app/DeskClockKotlin/DeskClockKotlin.apk

BUG: 157255731
Change-Id: Ia3ac367d2ba43fe0f08386a3f099878fd3af72d4
diff --git a/Android.bp b/Android.bp
index 6918211..4b93880 100644
--- a/Android.bp
+++ b/Android.bp
@@ -53,7 +53,13 @@
         "src/**/deskclock/data/CityModel.java",
         "src/**/deskclock/data/CustomRingtone.java",
         "src/**/deskclock/data/CustomRingtoneDAO.java",
+        "src/**/deskclock/data/Lap.java",
         "src/**/deskclock/data/RingtoneModel.java",
+        "src/**/deskclock/data/Stopwatch.java",
+        "src/**/deskclock/data/StopwatchDAO.java",
+        "src/**/deskclock/data/StopwatchListener.java",
+        "src/**/deskclock/data/StopwatchModel.java",
+        "src/**/deskclock/data/StopwatchNotificationBuilder.java",
         "src/**/deskclock/events/*.java",
         "src/**/deskclock/provider/*.java",
         "src/**/deskclock/settings/*.java",
diff --git a/src/com/android/deskclock/data/Lap.kt b/src/com/android/deskclock/data/Lap.kt
new file mode 100644
index 0000000..bb109ed
--- /dev/null
+++ b/src/com/android/deskclock/data/Lap.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.data
+
+/**
+ * A read-only domain object representing a stopwatch lap.
+ */
+data class Lap(
+    /** The 1-based position of the lap.  */
+    val lapNumber: Int,
+    /** Elapsed time in ms since the lap was last started.  */
+    val lapTime: Long,
+    /** Elapsed time in ms accumulated for all laps up to and including this one.  */
+    val accumulatedTime: Long
+)
\ No newline at end of file
diff --git a/src/com/android/deskclock/data/Stopwatch.kt b/src/com/android/deskclock/data/Stopwatch.kt
new file mode 100644
index 0000000..2953670
--- /dev/null
+++ b/src/com/android/deskclock/data/Stopwatch.kt
@@ -0,0 +1,139 @@
+/*
+ * 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.data
+
+import com.android.deskclock.Utils
+
+import kotlin.math.max
+
+/**
+ * A read-only domain object representing a stopwatch.
+ */
+class Stopwatch internal constructor(
+    /** Current state of this stopwatch.  */
+    val state: State,
+    /** Elapsed time in ms the stopwatch was last started; [.UNUSED] if not running.  */
+    val lastStartTime: Long,
+    /** The time since epoch at which the stopwatch was last started.  */
+    val lastWallClockTime: Long,
+    /** Elapsed time in ms this stopwatch has accumulated while running.  */
+    val accumulatedTime: Long
+) {
+
+    enum class State {
+        RESET, RUNNING, PAUSED
+    }
+
+    val isReset: Boolean
+        get() = state == State.RESET
+
+    val isPaused: Boolean
+        get() = state == State.PAUSED
+
+    val isRunning: Boolean
+        get() = state == State.RUNNING
+
+    /**
+     * @return the total amount of time accumulated up to this moment
+     */
+    val totalTime: Long
+        get() {
+            if (state != State.RUNNING) {
+                return accumulatedTime
+            }
+
+            // In practice, "now" can be any value due to device reboots. When the real-time clock
+            // is reset, there is no more guarantee that "now" falls after the last start time. To
+            // ensure the stopwatch is monotonically increasing, normalize negative time segments to
+            // 0
+            val timeSinceStart = Utils.now() - lastStartTime
+            return accumulatedTime + max(0, timeSinceStart)
+        }
+
+    /**
+     * @return a copy of this stopwatch that is running
+     */
+    fun start(): Stopwatch {
+        return if (state == State.RUNNING) {
+            this
+        } else {
+            Stopwatch(State.RUNNING, Utils.now(), Utils.wallClock(), totalTime)
+        }
+    }
+
+    /**
+     * @return a copy of this stopwatch that is paused
+     */
+    fun pause(): Stopwatch {
+        return if (state != State.RUNNING) {
+            this
+        } else {
+            Stopwatch(State.PAUSED, UNUSED, UNUSED, totalTime)
+        }
+    }
+
+    /**
+     * @return a copy of this stopwatch that is reset
+     */
+    fun reset(): Stopwatch = RESET_STOPWATCH
+
+    /**
+     * @return this Stopwatch if it is not running or an updated version based on wallclock time.
+     * The internals of the stopwatch are updated using the wallclock time which is durable
+     * across reboots.
+     */
+    fun updateAfterReboot(): Stopwatch {
+        if (state != State.RUNNING) {
+            return this
+        }
+        val timeSinceBoot = Utils.now()
+        val wallClockTime = Utils.wallClock()
+        // Avoid negative time deltas. They can happen in practice, but they can't be used. Simply
+        // update the recorded times and proceed with no change in accumulated time.
+        val delta = max(0, wallClockTime - lastWallClockTime)
+        return Stopwatch(state, timeSinceBoot, wallClockTime, accumulatedTime + delta)
+    }
+
+    /**
+     * @return this Stopwatch if it is not running or an updated version based on the realtime.
+     * The internals of the stopwatch are updated using the realtime clock which is accurate
+     * across wallclock time adjustments.
+     */
+    fun updateAfterTimeSet(): Stopwatch {
+        if (state != State.RUNNING) {
+            return this
+        }
+        val timeSinceBoot = Utils.now()
+        val wallClockTime = Utils.wallClock()
+        val delta = timeSinceBoot - lastStartTime
+        return if (delta < 0) {
+            // Avoid negative time deltas. They typically happen following reboots when TIME_SET is
+            // broadcast before BOOT_COMPLETED. Simply ignore the time update and hope
+            // updateAfterReboot() can successfully correct the data at a later time.
+            this
+        } else {
+            Stopwatch(state, timeSinceBoot, wallClockTime, accumulatedTime + delta)
+        }
+    }
+
+    companion object {
+        const val UNUSED = Long.MIN_VALUE
+
+        /** The single, immutable instance of a reset stopwatch.  */
+        private val RESET_STOPWATCH = Stopwatch(State.RESET, UNUSED, UNUSED, 0)
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/deskclock/data/StopwatchDAO.kt b/src/com/android/deskclock/data/StopwatchDAO.kt
new file mode 100644
index 0000000..5e049fe
--- /dev/null
+++ b/src/com/android/deskclock/data/StopwatchDAO.kt
@@ -0,0 +1,141 @@
+/*
+ * 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.data
+
+import android.content.SharedPreferences
+
+/**
+ * This class encapsulates the transfer of data between [Stopwatch] and [Lap] domain
+ * objects and their permanent storage in [SharedPreferences].
+ */
+internal object StopwatchDAO {
+    /** Key to a preference that stores the state of the stopwatch.  */
+    private const val STATE = "sw_state"
+
+    /** Key to a preference that stores the last start time of the stopwatch.  */
+    private const val LAST_START_TIME = "sw_start_time"
+
+    /** Key to a preference that stores the epoch time when the stopwatch last started.  */
+    private const val LAST_WALL_CLOCK_TIME = "sw_wall_clock_time"
+
+    /** Key to a preference that stores the accumulated elapsed time of the stopwatch.  */
+    private const val ACCUMULATED_TIME = "sw_accum_time"
+
+    /** Prefix for a key to a preference that stores the number of recorded laps.  */
+    private const val LAP_COUNT = "sw_lap_num"
+
+    /** Prefix for a key to a preference that stores accumulated time at the end of a lap.  */
+    private const val LAP_ACCUMULATED_TIME = "sw_lap_time_"
+
+    /**
+     * @return the stopwatch from permanent storage or a reset stopwatch if none exists
+     */
+    fun getStopwatch(prefs: SharedPreferences): Stopwatch {
+        val stateIndex: Int = prefs.getInt(STATE, Stopwatch.State.RESET.ordinal)
+        val state = Stopwatch.State.values()[stateIndex]
+        val lastStartTime: Long = prefs.getLong(LAST_START_TIME, Stopwatch.UNUSED)
+        val lastWallClockTime: Long = prefs.getLong(LAST_WALL_CLOCK_TIME, Stopwatch.UNUSED)
+        val accumulatedTime: Long = prefs.getLong(ACCUMULATED_TIME, 0)
+        var s = Stopwatch(state, lastStartTime, lastWallClockTime, accumulatedTime)
+
+        // If the stopwatch reports an illegal (negative) amount of time, remove the bad data.
+        if (s.totalTime < 0) {
+            s = s.reset()
+            setStopwatch(prefs, s)
+        }
+        return s
+    }
+
+    /**
+     * @param stopwatch the last state of the stopwatch
+     */
+    fun setStopwatch(prefs: SharedPreferences, stopwatch: Stopwatch) {
+        val editor: SharedPreferences.Editor = prefs.edit()
+
+        if (stopwatch.isReset) {
+            editor.remove(STATE)
+                    .remove(LAST_START_TIME)
+                    .remove(LAST_WALL_CLOCK_TIME)
+                    .remove(ACCUMULATED_TIME)
+        } else {
+            editor.putInt(STATE, stopwatch.state.ordinal)
+                    .putLong(LAST_START_TIME, stopwatch.lastStartTime)
+                    .putLong(LAST_WALL_CLOCK_TIME, stopwatch.lastWallClockTime)
+                    .putLong(ACCUMULATED_TIME, stopwatch.accumulatedTime)
+        }
+
+        editor.apply()
+    }
+
+    /**
+     * @return a list of recorded laps for the stopwatch
+     */
+    fun getLaps(prefs: SharedPreferences): MutableList<Lap> {
+        // Prepare the container to be filled with laps.
+        val lapCount: Int = prefs.getInt(LAP_COUNT, 0)
+        val laps: MutableList<Lap> = mutableListOf()
+
+        var prevAccumulatedTime: Long = 0
+
+        // Lap numbers are 1-based and so the are corresponding shared preference keys.
+        for (lapNumber in 1..lapCount) {
+            // Look up the accumulated time for the lap.
+            val lapAccumulatedTimeKey = LAP_ACCUMULATED_TIME + lapNumber
+            val accumulatedTime: Long = prefs.getLong(lapAccumulatedTimeKey, 0)
+
+            // Lap time is the delta between accumulated time of this lap and prior lap.
+            val lapTime = accumulatedTime - prevAccumulatedTime
+
+            // Create the lap instance from the data.
+            laps.add(Lap(lapNumber, lapTime, accumulatedTime))
+
+            // Update the accumulated time of the previous lap.
+            prevAccumulatedTime = accumulatedTime
+        }
+
+        // Laps are stored in the order they were recorded; display order is the reverse.
+        laps.reverse()
+
+        return laps
+    }
+
+    /**
+     * @param newLapCount the number of laps including the new lap
+     * @param accumulatedTime the amount of time accumulate by the stopwatch at the end of the lap
+     */
+    fun addLap(prefs: SharedPreferences, newLapCount: Int, accumulatedTime: Long) {
+        prefs.edit()
+                .putInt(LAP_COUNT, newLapCount)
+                .putLong(LAP_ACCUMULATED_TIME + newLapCount, accumulatedTime)
+                .apply()
+    }
+
+    /**
+     * Remove the recorded laps for the stopwatch
+     */
+    fun clearLaps(prefs: SharedPreferences) {
+        val editor: SharedPreferences.Editor = prefs.edit()
+
+        val lapCount: Int = prefs.getInt(LAP_COUNT, 0)
+        for (lapNumber in 1..lapCount) {
+            editor.remove(LAP_ACCUMULATED_TIME + lapNumber)
+        }
+        editor.remove(LAP_COUNT)
+
+        editor.apply()
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/deskclock/data/StopwatchListener.kt b/src/com/android/deskclock/data/StopwatchListener.kt
new file mode 100644
index 0000000..334662f
--- /dev/null
+++ b/src/com/android/deskclock/data/StopwatchListener.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.data
+
+/**
+ * The interface through which interested parties are notified of changes to the stopwatch or laps.
+ */
+interface StopwatchListener {
+    /**
+     * @param before the stopwatch state before the update
+     * @param after the stopwatch state after the update
+     */
+    fun stopwatchUpdated(before: Stopwatch, after: Stopwatch)
+
+    /**
+     * @param lap the lap that was added
+     */
+    fun lapAdded(lap: Lap)
+}
\ No newline at end of file
diff --git a/src/com/android/deskclock/data/StopwatchModel.kt b/src/com/android/deskclock/data/StopwatchModel.kt
new file mode 100644
index 0000000..c3e022f
--- /dev/null
+++ b/src/com/android/deskclock/data/StopwatchModel.kt
@@ -0,0 +1,244 @@
+/*
+ * 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.data
+
+import android.app.Notification
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.SharedPreferences
+import androidx.annotation.VisibleForTesting
+import androidx.core.app.NotificationManagerCompat
+
+import kotlin.math.max
+
+/**
+ * All [Stopwatch] data is accessed via this model.
+ */
+internal class StopwatchModel(
+    private val mContext: Context,
+    private val mPrefs: SharedPreferences,
+    /** The model from which notification data are fetched.  */
+    private val mNotificationModel: NotificationModel
+) {
+
+    /** Used to create and destroy system notifications related to the stopwatch.  */
+    private val mNotificationManager = NotificationManagerCompat.from(mContext)
+
+    /** Update stopwatch notification when locale changes.  */
+    private val mLocaleChangedReceiver: BroadcastReceiver = LocaleChangedReceiver()
+
+    /** The listeners to notify when the stopwatch or its laps change.  */
+    private val mStopwatchListeners: MutableList<StopwatchListener> = mutableListOf()
+
+    /** Delegate that builds platform-specific stopwatch notifications.  */
+    private val mNotificationBuilder = StopwatchNotificationBuilder()
+
+    /** The current state of the stopwatch.  */
+    private var mStopwatch: Stopwatch? = null
+
+    /** A mutable copy of the recorded stopwatch laps.  */
+    private var mLaps: MutableList<Lap>? = null
+
+    init {
+        // Update stopwatch notification when locale changes.
+        val localeBroadcastFilter = IntentFilter(Intent.ACTION_LOCALE_CHANGED)
+        mContext.registerReceiver(mLocaleChangedReceiver, localeBroadcastFilter)
+    }
+
+    /**
+     * @param stopwatchListener to be notified when stopwatch changes or laps are added
+     */
+    fun addStopwatchListener(stopwatchListener: StopwatchListener) {
+        mStopwatchListeners.add(stopwatchListener)
+    }
+
+    /**
+     * @param stopwatchListener to no longer be notified when stopwatch changes or laps are added
+     */
+    fun removeStopwatchListener(stopwatchListener: StopwatchListener) {
+        mStopwatchListeners.remove(stopwatchListener)
+    }
+
+    /**
+     * @return the current state of the stopwatch
+     */
+    val stopwatch: Stopwatch
+        get() {
+            if (mStopwatch == null) {
+                mStopwatch = StopwatchDAO.getStopwatch(mPrefs)
+            }
+
+            return mStopwatch!!
+        }
+
+    /**
+     * @param stopwatch the new state of the stopwatch
+     */
+    fun setStopwatch(stopwatch: Stopwatch): Stopwatch {
+        val before = this.stopwatch
+        if (before != stopwatch) {
+            StopwatchDAO.setStopwatch(mPrefs, stopwatch)
+            mStopwatch = stopwatch
+
+            // Refresh the stopwatch notification to reflect the latest stopwatch state.
+            if (!mNotificationModel.isApplicationInForeground) {
+                updateNotification()
+            }
+
+            // Resetting the stopwatch implicitly clears the recorded laps.
+            if (stopwatch.isReset) {
+                clearLaps()
+            }
+
+            // Notify listeners of the stopwatch change.
+            for (stopwatchListener in mStopwatchListeners) {
+                stopwatchListener.stopwatchUpdated(before, stopwatch)
+            }
+        }
+
+        return stopwatch
+    }
+
+    /**
+     * @return the laps recorded for this stopwatch
+     */
+    val laps: List<Lap>
+        get() = mutableLaps
+
+    /**
+     * @return a newly recorded lap completed now; `null` if no more laps can be added
+     */
+    fun addLap(): Lap? {
+        if (!mStopwatch!!.isRunning || !canAddMoreLaps()) {
+            return null
+        }
+
+        val totalTime = stopwatch.totalTime
+        val laps: MutableList<Lap> = mutableLaps
+
+        val lapNumber = laps.size + 1
+        StopwatchDAO.addLap(mPrefs, lapNumber, totalTime)
+
+        val prevAccumulatedTime = if (laps.isEmpty()) 0 else laps[0].accumulatedTime
+        val lapTime = totalTime - prevAccumulatedTime
+
+        val lap = Lap(lapNumber, lapTime, totalTime)
+        laps.add(0, lap)
+
+        // Refresh the stopwatch notification to reflect the latest stopwatch state.
+        if (!mNotificationModel.isApplicationInForeground) {
+            updateNotification()
+        }
+
+        // Notify listeners of the new lap.
+        for (stopwatchListener in mStopwatchListeners) {
+            stopwatchListener.lapAdded(lap)
+        }
+
+        return lap
+    }
+
+    /**
+     * Clears the laps recorded for this stopwatch.
+     */
+    @VisibleForTesting
+    fun clearLaps() {
+        StopwatchDAO.clearLaps(mPrefs)
+        mutableLaps.clear()
+    }
+
+    /**
+     * @return `true` iff more laps can be recorded
+     */
+    fun canAddMoreLaps(): Boolean = laps.size < 98
+
+    /**
+     * @return the longest lap time of all recorded laps and the current lap
+     */
+    val longestLapTime: Long
+        get() {
+            var maxLapTime: Long = 0
+
+            val laps = laps
+            if (laps.isNotEmpty()) {
+                // Compute the maximum lap time across all recorded laps.
+                for (lap in laps) {
+                    maxLapTime = max(maxLapTime, lap.lapTime)
+                }
+
+                // Compare with the maximum lap time for the current lap.
+                val stopwatch = stopwatch
+                val currentLapTime = stopwatch.totalTime - laps[0].accumulatedTime
+                maxLapTime = max(maxLapTime, currentLapTime)
+            }
+
+            return maxLapTime
+        }
+
+    /**
+     * In practice, `time` can be any value due to device reboots. When the real-time clock is
+     * reset, there is no more guarantee that this time falls after the last recorded lap.
+     *
+     * @param time a point in time expected, but not required, to be after the end of the prior lap
+     * @return the elapsed time between the given `time` and the end of the prior lap;
+     * negative elapsed times are normalized to `0`
+     */
+    fun getCurrentLapTime(time: Long): Long {
+        val previousLap = laps[0]
+        val currentLapTime = time - previousLap.accumulatedTime
+        return max(0, currentLapTime)
+    }
+
+    /**
+     * Updates the notification to reflect the latest state of the stopwatch and recorded laps.
+     */
+    fun updateNotification() {
+        val stopwatch = stopwatch
+
+        // Notification should be hidden if the stopwatch has no time or the app is open.
+        if (stopwatch.isReset || mNotificationModel.isApplicationInForeground) {
+            mNotificationManager.cancel(mNotificationModel.stopwatchNotificationId)
+            return
+        }
+
+        // Otherwise build and post a notification reflecting the latest stopwatch state.
+        val notification: Notification =
+                mNotificationBuilder.build(mContext, mNotificationModel, stopwatch)
+        mNotificationBuilder.buildChannel(mContext, mNotificationManager)
+        mNotificationManager.notify(mNotificationModel.stopwatchNotificationId, notification)
+    }
+
+    private val mutableLaps: MutableList<Lap>
+        get() {
+            if (mLaps == null) {
+                mLaps = StopwatchDAO.getLaps(mPrefs)
+            }
+
+            return mLaps!!
+        }
+
+    /**
+     * Update the stopwatch notification in response to a locale change.
+     */
+    private inner class LocaleChangedReceiver : BroadcastReceiver() {
+        override fun onReceive(context: Context?, intent: Intent?) {
+            updateNotification()
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/deskclock/data/StopwatchNotificationBuilder.kt b/src/com/android/deskclock/data/StopwatchNotificationBuilder.kt
new file mode 100644
index 0000000..7671b05
--- /dev/null
+++ b/src/com/android/deskclock/data/StopwatchNotificationBuilder.kt
@@ -0,0 +1,165 @@
+/*
+ * 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.data
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.res.Resources
+import android.os.SystemClock
+import android.view.View.GONE
+import android.view.View.VISIBLE
+import android.widget.RemoteViews
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationCompat.Action
+import androidx.core.app.NotificationCompat.Builder
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+
+import com.android.deskclock.R
+import com.android.deskclock.Utils
+import com.android.deskclock.events.Events
+import com.android.deskclock.stopwatch.StopwatchService
+
+/**
+ * Builds notification to reflect the latest state of the stopwatch and recorded laps.
+ */
+internal class StopwatchNotificationBuilder {
+    fun buildChannel(context: Context, notificationManager: NotificationManagerCompat) {
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+            val channel = NotificationChannel(
+                    STOPWATCH_NOTIFICATION_CHANNEL_ID,
+                    context.getString(R.string.default_label),
+                    NotificationManagerCompat.IMPORTANCE_DEFAULT)
+            notificationManager.createNotificationChannel(channel)
+        }
+    }
+
+    fun build(context: Context, nm: NotificationModel, stopwatch: Stopwatch?): Notification {
+        @StringRes val eventLabel: Int = R.string.label_notification
+
+        // Intent to load the app when the notification is tapped.
+        val showApp: Intent = Intent(context, StopwatchService::class.java)
+                .setAction(StopwatchService.ACTION_SHOW_STOPWATCH)
+                .putExtra(Events.EXTRA_EVENT_LABEL, eventLabel)
+
+        val pendingShowApp: PendingIntent = PendingIntent.getService(context, 0, showApp,
+                PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT)
+
+        // Compute some values required below.
+        val running = stopwatch!!.isRunning
+        val pname: String = context.getPackageName()
+        val res: Resources = context.getResources()
+        val base: Long = SystemClock.elapsedRealtime() - stopwatch.totalTime
+
+        val content = RemoteViews(pname, R.layout.chronometer_notif_content)
+        content.setChronometer(R.id.chronometer, base, null, running)
+
+        val actions: MutableList<Action> = ArrayList<Action>(2)
+
+        if (running) {
+            // Left button: Pause
+            val pause: Intent = Intent(context, StopwatchService::class.java)
+                    .setAction(StopwatchService.ACTION_PAUSE_STOPWATCH)
+                    .putExtra(Events.EXTRA_EVENT_LABEL, eventLabel)
+
+            @DrawableRes val icon1: Int = R.drawable.ic_pause_24dp
+            val title1: CharSequence = res.getText(R.string.sw_pause_button)
+            val intent1: PendingIntent = Utils.pendingServiceIntent(context, pause)
+            actions.add(Action.Builder(icon1, title1, intent1).build())
+
+            // Right button: Add Lap
+            if (DataModel.getDataModel().canAddMoreLaps()) {
+                val lap: Intent = Intent(context, StopwatchService::class.java)
+                        .setAction(StopwatchService.ACTION_LAP_STOPWATCH)
+                        .putExtra(Events.EXTRA_EVENT_LABEL, eventLabel)
+
+                @DrawableRes val icon2: Int = R.drawable.ic_sw_lap_24dp
+                val title2: CharSequence = res.getText(R.string.sw_lap_button)
+                val intent2: PendingIntent = Utils.pendingServiceIntent(context, lap)
+                actions.add(Action.Builder(icon2, title2, intent2).build())
+            }
+
+            // Show the current lap number if any laps have been recorded.
+            val lapCount = DataModel.getDataModel().laps.size
+            if (lapCount > 0) {
+                val lapNumber = lapCount + 1
+                val lap: String = res.getString(R.string.sw_notification_lap_number, lapNumber)
+                content.setTextViewText(R.id.state, lap)
+                content.setViewVisibility(R.id.state, VISIBLE)
+            } else {
+                content.setViewVisibility(R.id.state, GONE)
+            }
+        } else {
+            // Left button: Start
+            val start: Intent = Intent(context, StopwatchService::class.java)
+                    .setAction(StopwatchService.ACTION_START_STOPWATCH)
+                    .putExtra(Events.EXTRA_EVENT_LABEL, eventLabel)
+
+            @DrawableRes val icon1: Int = R.drawable.ic_start_24dp
+            val title1: CharSequence = res.getText(R.string.sw_start_button)
+            val intent1: PendingIntent = Utils.pendingServiceIntent(context, start)
+            actions.add(Action.Builder(icon1, title1, intent1).build())
+
+            // Right button: Reset (dismisses notification and resets stopwatch)
+            val reset: Intent = Intent(context, StopwatchService::class.java)
+                    .setAction(StopwatchService.ACTION_RESET_STOPWATCH)
+                    .putExtra(Events.EXTRA_EVENT_LABEL, eventLabel)
+
+            @DrawableRes val icon2: Int = R.drawable.ic_reset_24dp
+            val title2: CharSequence = res.getText(R.string.sw_reset_button)
+            val intent2: PendingIntent = Utils.pendingServiceIntent(context, reset)
+            actions.add(Action.Builder(icon2, title2, intent2).build())
+
+            // Indicate the stopwatch is paused.
+            content.setTextViewText(R.id.state, res.getString(R.string.swn_paused))
+            content.setViewVisibility(R.id.state, VISIBLE)
+        }
+        val notification: Builder = Builder(
+                context, STOPWATCH_NOTIFICATION_CHANNEL_ID)
+                .setLocalOnly(true)
+                .setOngoing(running)
+                .setCustomContentView(content)
+                .setContentIntent(pendingShowApp)
+                .setAutoCancel(stopwatch.isPaused)
+                .setPriority(NotificationManagerCompat.IMPORTANCE_HIGH)
+                .setSmallIcon(R.drawable.stat_notify_stopwatch)
+                .setStyle(NotificationCompat.DecoratedCustomViewStyle())
+                .setColor(ContextCompat.getColor(context, R.color.default_background))
+
+        if (Utils.isNOrLater()) {
+            notification.setGroup(nm.stopwatchNotificationGroupKey)
+        }
+
+        for (action in actions) {
+            notification.addAction(action)
+        }
+
+        return notification.build()
+    }
+
+    companion object {
+        /**
+         * Notification channel containing all stopwatch notifications.
+         */
+        private const val STOPWATCH_NOTIFICATION_CHANNEL_ID = "StopwatchNotification"
+    }
+}
\ No newline at end of file