1 /*
2  * Copyright (C) 2015 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.app.PendingIntent;
21 import android.content.BroadcastReceiver;
22 import android.content.Context;
23 import android.content.Intent;
24 import android.content.IntentFilter;
25 import android.content.res.Resources;
26 import android.os.SystemClock;
27 import android.support.annotation.IdRes;
28 import android.support.annotation.StringRes;
29 import android.support.v4.app.NotificationCompat;
30 import android.support.v4.app.NotificationManagerCompat;
31 import android.widget.RemoteViews;
32 
33 import com.android.deskclock.HandleDeskClockApiCalls;
34 import com.android.deskclock.R;
35 import com.android.deskclock.stopwatch.StopwatchService;
36 
37 import java.util.Collections;
38 import java.util.List;
39 
40 import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
41 import static android.view.View.GONE;
42 import static android.view.View.INVISIBLE;
43 import static android.view.View.VISIBLE;
44 
45 /**
46  * All {@link Stopwatch} data is accessed via this model.
47  */
48 final class StopwatchModel {
49 
50     private final Context mContext;
51 
52     /** The model from which notification data are fetched. */
53     private final NotificationModel mNotificationModel;
54 
55     /** Used to create and destroy system notifications related to the stopwatch. */
56     private final NotificationManagerCompat mNotificationManager;
57 
58     /** Update stopwatch notification when locale changes. */
59     private final BroadcastReceiver mLocaleChangedReceiver = new LocaleChangedReceiver();
60 
61     /** The current state of the stopwatch. */
62     private Stopwatch mStopwatch;
63 
64     /** A mutable copy of the recorded stopwatch laps. */
65     private List<Lap> mLaps;
66 
StopwatchModel(Context context, NotificationModel notificationModel)67     StopwatchModel(Context context, NotificationModel notificationModel) {
68         mContext = context;
69         mNotificationModel = notificationModel;
70         mNotificationManager = NotificationManagerCompat.from(context);
71 
72         // Update stopwatch notification when locale changes.
73         final IntentFilter localeBroadcastFilter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED);
74         mContext.registerReceiver(mLocaleChangedReceiver, localeBroadcastFilter);
75     }
76 
77     /**
78      * @return the current state of the stopwatch
79      */
getStopwatch()80     Stopwatch getStopwatch() {
81         if (mStopwatch == null) {
82             mStopwatch = StopwatchDAO.getStopwatch(mContext);
83         }
84 
85         return mStopwatch;
86     }
87 
88     /**
89      * @param stopwatch the new state of the stopwatch
90      */
setStopwatch(Stopwatch stopwatch)91     Stopwatch setStopwatch(Stopwatch stopwatch) {
92         if (mStopwatch != stopwatch) {
93             StopwatchDAO.setStopwatch(mContext, stopwatch);
94             mStopwatch = stopwatch;
95 
96             // Refresh the stopwatch notification to reflect the latest stopwatch state.
97             if (!mNotificationModel.isApplicationInForeground()) {
98                 updateNotification();
99             }
100         }
101 
102         return stopwatch;
103     }
104 
105     /**
106      * @return the laps recorded for this stopwatch
107      */
getLaps()108     List<Lap> getLaps() {
109         return Collections.unmodifiableList(getMutableLaps());
110     }
111 
112     /**
113      * @return a newly recorded lap completed now; {@code null} if no more laps can be added
114      */
addLap()115     Lap addLap() {
116         if (!canAddMoreLaps()) {
117             return null;
118         }
119 
120         final long totalTime = getStopwatch().getTotalTime();
121         final List<Lap> laps = getMutableLaps();
122 
123         final int lapNumber = laps.size() + 1;
124         StopwatchDAO.addLap(mContext, lapNumber, totalTime);
125 
126         final long prevAccumulatedTime = laps.isEmpty() ? 0 : laps.get(0).getAccumulatedTime();
127         final long lapTime = totalTime - prevAccumulatedTime;
128 
129         final Lap lap = new Lap(lapNumber, lapTime, totalTime);
130         laps.add(0, lap);
131 
132         // Refresh the stopwatch notification to reflect the latest stopwatch state.
133         if (!mNotificationModel.isApplicationInForeground()) {
134             updateNotification();
135         }
136 
137         return lap;
138     }
139 
140     /**
141      * Clears the laps recorded for this stopwatch.
142      */
clearLaps()143     void clearLaps() {
144         StopwatchDAO.clearLaps(mContext);
145         getMutableLaps().clear();
146     }
147 
148     /**
149      * @return {@code true} iff more laps can be recorded
150      */
canAddMoreLaps()151     boolean canAddMoreLaps() {
152         return getLaps().size() < 98;
153     }
154 
155     /**
156      * @return the longest lap time of all recorded laps and the current lap
157      */
getLongestLapTime()158     long getLongestLapTime() {
159         long maxLapTime = 0;
160 
161         final List<Lap> laps = getLaps();
162         if (!laps.isEmpty()) {
163             // Compute the maximum lap time across all recorded laps.
164             for (Lap lap : getLaps()) {
165                 maxLapTime = Math.max(maxLapTime, lap.getLapTime());
166             }
167 
168             // Compare with the maximum lap time for the current lap.
169             final Stopwatch stopwatch = getStopwatch();
170             final long currentLapTime = stopwatch.getTotalTime() - laps.get(0).getAccumulatedTime();
171             maxLapTime = Math.max(maxLapTime, currentLapTime);
172         }
173 
174         return maxLapTime;
175     }
176 
177     /**
178      * In practice, {@code time} can be any value due to device reboots. When the real-time clock is
179      * reset, there is no more guarantee that this time falls after the last recorded lap.
180      *
181      * @param time a point in time expected, but not required, to be after the end of the prior lap
182      * @return the elapsed time between the given {@code time} and the end of the prior lap;
183      *      negative elapsed times are normalized to {@code 0}
184      */
getCurrentLapTime(long time)185     long getCurrentLapTime(long time) {
186         final Lap previousLap = getLaps().get(0);
187         final long currentLapTime = time - previousLap.getAccumulatedTime();
188         return Math.max(0, currentLapTime);
189     }
190 
191     /**
192      * Updates the notification to reflect the latest state of the stopwatch and recorded laps.
193      */
updateNotification()194     void updateNotification() {
195         final Stopwatch stopwatch = getStopwatch();
196 
197         // Notification should be hidden if the stopwatch has no time or the app is open.
198         if (stopwatch.isReset() || mNotificationModel.isApplicationInForeground()) {
199             mNotificationManager.cancel(mNotificationModel.getStopwatchNotificationId());
200             return;
201         }
202 
203         @StringRes final int eventLabel = R.string.label_notification;
204 
205         // Intent to load the app when the notification is tapped.
206         final Intent showApp = new Intent(mContext, HandleDeskClockApiCalls.class)
207                 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
208                 .setAction(HandleDeskClockApiCalls.ACTION_SHOW_STOPWATCH)
209                 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel);
210 
211         final PendingIntent pendingShowApp = PendingIntent.getActivity(mContext, 0, showApp,
212                 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
213 
214         // Compute some values required below.
215         final boolean running = stopwatch.isRunning();
216         final String pname = mContext.getPackageName();
217         final Resources res = mContext.getResources();
218         final long base = SystemClock.elapsedRealtime() - stopwatch.getTotalTime();
219 
220         final RemoteViews collapsed = new RemoteViews(pname, R.layout.stopwatch_notif_collapsed);
221         collapsed.setChronometer(R.id.swn_collapsed_chronometer, base, null, running);
222         collapsed.setOnClickPendingIntent(R.id.swn_collapsed_hitspace, pendingShowApp);
223         collapsed.setImageViewResource(R.id.notification_icon, R.drawable.stat_notify_stopwatch);
224 
225         final RemoteViews expanded = new RemoteViews(pname, R.layout.stopwatch_notif_expanded);
226         expanded.setChronometer(R.id.swn_expanded_chronometer, base, null, running);
227         expanded.setOnClickPendingIntent(R.id.swn_expanded_hitspace, pendingShowApp);
228         expanded.setImageViewResource(R.id.notification_icon, R.drawable.stat_notify_stopwatch);
229 
230         @IdRes final int leftButtonId = R.id.swn_left_button;
231         @IdRes final int rightButtonId = R.id.swn_right_button;
232         if (running) {
233             // Left button: Pause
234             expanded.setTextViewText(leftButtonId, res.getText(R.string.sw_pause_button));
235             setTextViewDrawable(expanded, leftButtonId, R.drawable.ic_pause_24dp);
236             final Intent pause = new Intent(mContext, StopwatchService.class)
237                     .setAction(HandleDeskClockApiCalls.ACTION_PAUSE_STOPWATCH)
238                     .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel);
239             expanded.setOnClickPendingIntent(leftButtonId, pendingServiceIntent(pause));
240 
241             // Right button: Add Lap
242             if (canAddMoreLaps()) {
243                 expanded.setTextViewText(rightButtonId, res.getText(R.string.sw_lap_button));
244                 setTextViewDrawable(expanded, rightButtonId, R.drawable.ic_sw_lap_24dp);
245 
246                 final Intent lap = new Intent(mContext, StopwatchService.class)
247                         .setAction(HandleDeskClockApiCalls.ACTION_LAP_STOPWATCH)
248                         .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel);
249                 expanded.setOnClickPendingIntent(rightButtonId, pendingServiceIntent(lap));
250                 expanded.setViewVisibility(rightButtonId, VISIBLE);
251             } else {
252                 expanded.setViewVisibility(rightButtonId, INVISIBLE);
253             }
254 
255             // Show the current lap number if any laps have been recorded.
256             final int lapCount = getLaps().size();
257             if (lapCount > 0) {
258                 final int lapNumber = lapCount + 1;
259                 final String lap = res.getString(R.string.sw_notification_lap_number, lapNumber);
260                 collapsed.setTextViewText(R.id.swn_collapsed_laps, lap);
261                 collapsed.setViewVisibility(R.id.swn_collapsed_laps, VISIBLE);
262                 expanded.setTextViewText(R.id.swn_expanded_laps, lap);
263                 expanded.setViewVisibility(R.id.swn_expanded_laps, VISIBLE);
264             } else {
265                 collapsed.setViewVisibility(R.id.swn_collapsed_laps, GONE);
266                 expanded.setViewVisibility(R.id.swn_expanded_laps, GONE);
267             }
268         } else {
269             // Left button: Start
270             expanded.setTextViewText(leftButtonId, res.getText(R.string.sw_start_button));
271             setTextViewDrawable(expanded, leftButtonId, R.drawable.ic_start_24dp);
272             final Intent start = new Intent(mContext, StopwatchService.class)
273                     .setAction(HandleDeskClockApiCalls.ACTION_START_STOPWATCH)
274                     .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel);
275             expanded.setOnClickPendingIntent(leftButtonId, pendingServiceIntent(start));
276 
277             // Right button: Reset (HandleDeskClockApiCalls will also bring forward the app)
278             expanded.setViewVisibility(rightButtonId, VISIBLE);
279             expanded.setTextViewText(rightButtonId, res.getText(R.string.sw_reset_button));
280             setTextViewDrawable(expanded, rightButtonId, R.drawable.ic_reset_24dp);
281             final Intent reset = new Intent(mContext, HandleDeskClockApiCalls.class)
282                     .setAction(HandleDeskClockApiCalls.ACTION_RESET_STOPWATCH)
283                     .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel);
284             expanded.setOnClickPendingIntent(rightButtonId, pendingActivityIntent(reset));
285 
286             // Indicate the stopwatch is paused.
287             collapsed.setTextViewText(R.id.swn_collapsed_laps, res.getString(R.string.swn_paused));
288             collapsed.setViewVisibility(R.id.swn_collapsed_laps, VISIBLE);
289             expanded.setTextViewText(R.id.swn_expanded_laps, res.getString(R.string.swn_paused));
290             expanded.setViewVisibility(R.id.swn_expanded_laps, VISIBLE);
291         }
292 
293         // Swipe away will reset the stopwatch without bringing forward the app.
294         final Intent reset = new Intent(mContext, StopwatchService.class)
295                 .setAction(HandleDeskClockApiCalls.ACTION_RESET_STOPWATCH)
296                 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, eventLabel);
297 
298         final Notification notification = new NotificationCompat.Builder(mContext)
299                 .setLocalOnly(true)
300                 .setOngoing(running)
301                 .setContent(collapsed)
302                 .setAutoCancel(stopwatch.isPaused())
303                 .setPriority(Notification.PRIORITY_MAX)
304                 .setDeleteIntent(pendingServiceIntent(reset))
305                 .setSmallIcon(R.drawable.ic_tab_stopwatch_activated)
306                 .build();
307         notification.bigContentView = expanded;
308         mNotificationManager.notify(mNotificationModel.getStopwatchNotificationId(), notification);
309     }
310 
pendingServiceIntent(Intent intent)311     private PendingIntent pendingServiceIntent(Intent intent) {
312         return PendingIntent.getService(mContext, 0, intent, FLAG_UPDATE_CURRENT);
313     }
314 
pendingActivityIntent(Intent intent)315     private PendingIntent pendingActivityIntent(Intent intent) {
316         return PendingIntent.getActivity(mContext, 0, intent, FLAG_UPDATE_CURRENT);
317     }
318 
setTextViewDrawable(RemoteViews rv, int viewId, int drawableId)319     private static void setTextViewDrawable(RemoteViews rv, int viewId, int drawableId) {
320         rv.setTextViewCompoundDrawablesRelative(viewId, drawableId, 0, 0, 0);
321     }
322 
getMutableLaps()323     private List<Lap> getMutableLaps() {
324         if (mLaps == null) {
325             mLaps = StopwatchDAO.getLaps(mContext);
326         }
327 
328         return mLaps;
329     }
330 
331     /**
332      * Update the stopwatch notification in response to a locale change.
333      */
334     private final class LocaleChangedReceiver extends BroadcastReceiver {
335         @Override
onReceive(Context context, Intent intent)336         public void onReceive(Context context, Intent intent) {
337             updateNotification();
338         }
339     }
340 }