1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.deskclock.data
18 
19 import android.app.Notification
20 import android.content.BroadcastReceiver
21 import android.content.Context
22 import android.content.Intent
23 import android.content.IntentFilter
24 import android.content.SharedPreferences
25 import androidx.annotation.VisibleForTesting
26 import androidx.core.app.NotificationManagerCompat
27 
28 import kotlin.math.max
29 
30 /**
31  * All [Stopwatch] data is accessed via this model.
32  */
33 internal class StopwatchModel(
34     private val mContext: Context,
35     private val mPrefs: SharedPreferences,
36     /** The model from which notification data are fetched.  */
37     private val mNotificationModel: NotificationModel
38 ) {
39 
40     /** Used to create and destroy system notifications related to the stopwatch.  */
41     private val mNotificationManager = NotificationManagerCompat.from(mContext)
42 
43     /** Update stopwatch notification when locale changes.  */
44     private val mLocaleChangedReceiver: BroadcastReceiver = LocaleChangedReceiver()
45 
46     /** The listeners to notify when the stopwatch or its laps change.  */
47     private val mStopwatchListeners: MutableList<StopwatchListener> = mutableListOf()
48 
49     /** Delegate that builds platform-specific stopwatch notifications.  */
50     private val mNotificationBuilder = StopwatchNotificationBuilder()
51 
52     /** The current state of the stopwatch.  */
53     private var mStopwatch: Stopwatch? = null
54 
55     /** A mutable copy of the recorded stopwatch laps.  */
56     private var mLaps: MutableList<Lap>? = null
57 
58     init {
59         // Update stopwatch notification when locale changes.
60         val localeBroadcastFilter = IntentFilter(Intent.ACTION_LOCALE_CHANGED)
61         mContext.registerReceiver(mLocaleChangedReceiver, localeBroadcastFilter)
62     }
63 
64     /**
65      * @param stopwatchListener to be notified when stopwatch changes or laps are added
66      */
addStopwatchListenernull67     fun addStopwatchListener(stopwatchListener: StopwatchListener) {
68         mStopwatchListeners.add(stopwatchListener)
69     }
70 
71     /**
72      * @param stopwatchListener to no longer be notified when stopwatch changes or laps are added
73      */
removeStopwatchListenernull74     fun removeStopwatchListener(stopwatchListener: StopwatchListener) {
75         mStopwatchListeners.remove(stopwatchListener)
76     }
77 
78     /**
79      * @return the current state of the stopwatch
80      */
81     val stopwatch: Stopwatch
82         get() {
83             if (mStopwatch == null) {
84                 mStopwatch = StopwatchDAO.getStopwatch(mPrefs)
85             }
86 
87             return mStopwatch!!
88         }
89 
90     /**
91      * @param stopwatch the new state of the stopwatch
92      */
setStopwatchnull93     fun setStopwatch(stopwatch: Stopwatch): Stopwatch {
94         val before = this.stopwatch
95         if (before != stopwatch) {
96             StopwatchDAO.setStopwatch(mPrefs, stopwatch)
97             mStopwatch = stopwatch
98 
99             // Refresh the stopwatch notification to reflect the latest stopwatch state.
100             if (!mNotificationModel.isApplicationInForeground) {
101                 updateNotification()
102             }
103 
104             // Resetting the stopwatch implicitly clears the recorded laps.
105             if (stopwatch.isReset) {
106                 clearLaps()
107             }
108 
109             // Notify listeners of the stopwatch change.
110             for (stopwatchListener in mStopwatchListeners) {
111                 stopwatchListener.stopwatchUpdated(before, stopwatch)
112             }
113         }
114 
115         return stopwatch
116     }
117 
118     /**
119      * @return the laps recorded for this stopwatch
120      */
121     val laps: List<Lap>
122         get() = mutableLaps
123 
124     /**
125      * @return a newly recorded lap completed now; `null` if no more laps can be added
126      */
addLapnull127     fun addLap(): Lap? {
128         if (!mStopwatch!!.isRunning || !canAddMoreLaps()) {
129             return null
130         }
131 
132         val totalTime = stopwatch.totalTime
133         val laps: MutableList<Lap> = mutableLaps
134 
135         val lapNumber = laps.size + 1
136         StopwatchDAO.addLap(mPrefs, lapNumber, totalTime)
137 
138         val prevAccumulatedTime = if (laps.isEmpty()) 0 else laps[0].accumulatedTime
139         val lapTime = totalTime - prevAccumulatedTime
140 
141         val lap = Lap(lapNumber, lapTime, totalTime)
142         laps.add(0, lap)
143 
144         // Refresh the stopwatch notification to reflect the latest stopwatch state.
145         if (!mNotificationModel.isApplicationInForeground) {
146             updateNotification()
147         }
148 
149         // Notify listeners of the new lap.
150         for (stopwatchListener in mStopwatchListeners) {
151             stopwatchListener.lapAdded(lap)
152         }
153 
154         return lap
155     }
156 
157     /**
158      * Clears the laps recorded for this stopwatch.
159      */
160     @VisibleForTesting
clearLapsnull161     fun clearLaps() {
162         StopwatchDAO.clearLaps(mPrefs)
163         mutableLaps.clear()
164     }
165 
166     /**
167      * @return `true` iff more laps can be recorded
168      */
canAddMoreLapsnull169     fun canAddMoreLaps(): Boolean = laps.size < 98
170 
171     /**
172      * @return the longest lap time of all recorded laps and the current lap
173      */
174     val longestLapTime: Long
175         get() {
176             var maxLapTime: Long = 0
177 
178             val laps = laps
179             if (laps.isNotEmpty()) {
180                 // Compute the maximum lap time across all recorded laps.
181                 for (lap in laps) {
182                     maxLapTime = max(maxLapTime, lap.lapTime)
183                 }
184 
185                 // Compare with the maximum lap time for the current lap.
186                 val stopwatch = stopwatch
187                 val currentLapTime = stopwatch.totalTime - laps[0].accumulatedTime
188                 maxLapTime = max(maxLapTime, currentLapTime)
189             }
190 
191             return maxLapTime
192         }
193 
194     /**
195      * In practice, `time` can be any value due to device reboots. When the real-time clock is
196      * reset, there is no more guarantee that this time falls after the last recorded lap.
197      *
198      * @param time a point in time expected, but not required, to be after the end of the prior lap
199      * @return the elapsed time between the given `time` and the end of the prior lap;
200      * negative elapsed times are normalized to `0`
201      */
getCurrentLapTimenull202     fun getCurrentLapTime(time: Long): Long {
203         val previousLap = laps[0]
204         val currentLapTime = time - previousLap.accumulatedTime
205         return max(0, currentLapTime)
206     }
207 
208     /**
209      * Updates the notification to reflect the latest state of the stopwatch and recorded laps.
210      */
updateNotificationnull211     fun updateNotification() {
212         val stopwatch = stopwatch
213 
214         // Notification should be hidden if the stopwatch has no time or the app is open.
215         if (stopwatch.isReset || mNotificationModel.isApplicationInForeground) {
216             mNotificationManager.cancel(mNotificationModel.stopwatchNotificationId)
217             return
218         }
219 
220         // Otherwise build and post a notification reflecting the latest stopwatch state.
221         val notification: Notification =
222                 mNotificationBuilder.build(mContext, mNotificationModel, stopwatch)
223         mNotificationBuilder.buildChannel(mContext, mNotificationManager)
224         mNotificationManager.notify(mNotificationModel.stopwatchNotificationId, notification)
225     }
226 
227     private val mutableLaps: MutableList<Lap>
228         get() {
229             if (mLaps == null) {
230                 mLaps = StopwatchDAO.getLaps(mPrefs)
231             }
232 
233             return mLaps!!
234         }
235 
236     /**
237      * Update the stopwatch notification in response to a locale change.
238      */
239     private inner class LocaleChangedReceiver : BroadcastReceiver() {
onReceivenull240         override fun onReceive(context: Context?, intent: Intent?) {
241             updateNotification()
242         }
243     }
244 }