• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.AlarmManager;
20 import android.app.Notification;
21 import android.app.PendingIntent;
22 import android.app.Service;
23 import android.content.BroadcastReceiver;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.content.IntentFilter;
27 import android.content.SharedPreferences;
28 import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
29 import android.media.Ringtone;
30 import android.media.RingtoneManager;
31 import android.net.Uri;
32 import android.os.SystemClock;
33 import android.preference.PreferenceManager;
34 import android.support.annotation.DrawableRes;
35 import android.support.annotation.StringRes;
36 import android.support.annotation.VisibleForTesting;
37 import android.support.v4.app.NotificationCompat;
38 import android.support.v4.app.NotificationManagerCompat;
39 import android.text.TextUtils;
40 import android.util.ArraySet;
41 
42 import com.android.deskclock.AlarmAlertWakeLock;
43 import com.android.deskclock.HandleDeskClockApiCalls;
44 import com.android.deskclock.LogUtils;
45 import com.android.deskclock.R;
46 import com.android.deskclock.Utils;
47 import com.android.deskclock.events.Events;
48 import com.android.deskclock.settings.SettingsActivity;
49 import com.android.deskclock.timer.ExpiredTimersActivity;
50 import com.android.deskclock.timer.TimerKlaxon;
51 import com.android.deskclock.timer.TimerService;
52 
53 import java.util.ArrayList;
54 import java.util.Collections;
55 import java.util.List;
56 import java.util.Set;
57 
58 import static android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP;
59 import static android.text.format.DateUtils.HOUR_IN_MILLIS;
60 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
61 import static com.android.deskclock.data.Timer.State.EXPIRED;
62 import static com.android.deskclock.data.Timer.State.RESET;
63 
64 /**
65  * All {@link Timer} data is accessed via this model.
66  */
67 final class TimerModel {
68 
69     private final Context mContext;
70 
71     /** The alarm manager system service that calls back when timers expire. */
72     private final AlarmManager mAlarmManager;
73 
74     /** The model from which settings are fetched. */
75     private final SettingsModel mSettingsModel;
76 
77     /** The model from which notification data are fetched. */
78     private final NotificationModel mNotificationModel;
79 
80     /** Used to create and destroy system notifications related to timers. */
81     private final NotificationManagerCompat mNotificationManager;
82 
83     /** Update timer notification when locale changes. */
84     private final BroadcastReceiver mLocaleChangedReceiver = new LocaleChangedReceiver();
85 
86     /**
87      * Retain a hard reference to the shared preference observer to prevent it from being garbage
88      * collected. See {@link SharedPreferences#registerOnSharedPreferenceChangeListener} for detail.
89      */
90     private final OnSharedPreferenceChangeListener mPreferenceListener = new PreferenceListener();
91 
92     /** The listeners to notify when a timer is added, updated or removed. */
93     private final List<TimerListener> mTimerListeners = new ArrayList<>();
94 
95     /**
96      * The ids of expired timers for which the ringer is ringing. Not all expired timers have their
97      * ids in this collection. If a timer was already expired when the app was started its id will
98      * be absent from this collection.
99      */
100     private final Set<Integer> mRingingIds = new ArraySet<>();
101 
102     /** The uri of the ringtone to play for timers. */
103     private Uri mTimerRingtoneUri;
104 
105     /** The title of the ringtone to play for timers. */
106     private String mTimerRingtoneTitle;
107 
108     /** A mutable copy of the timers. */
109     private List<Timer> mTimers;
110 
111     /** A mutable copy of the expired timers. */
112     private List<Timer> mExpiredTimers;
113 
114     /**
115      * The service that keeps this application in the foreground while a heads-up timer
116      * notification is displayed. Marking the service as foreground prevents the operating system
117      * from killing this application while expired timers are actively firing.
118      */
119     private Service mService;
120 
TimerModel(Context context, SettingsModel settingsModel, NotificationModel notificationModel)121     TimerModel(Context context, SettingsModel settingsModel, NotificationModel notificationModel) {
122         mContext = context;
123         mSettingsModel = settingsModel;
124         mNotificationModel = notificationModel;
125         mNotificationManager = NotificationManagerCompat.from(context);
126 
127         mAlarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
128 
129         // Clear caches affected by preferences when preferences change.
130         final SharedPreferences prefs = Utils.getDefaultSharedPreferences(mContext);
131         prefs.registerOnSharedPreferenceChangeListener(mPreferenceListener);
132 
133         // Update stopwatch notification when locale changes.
134         final IntentFilter localeBroadcastFilter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED);
135         mContext.registerReceiver(mLocaleChangedReceiver, localeBroadcastFilter);
136     }
137 
138     /**
139      * @param timerListener to be notified when timers are added, updated and removed
140      */
addTimerListener(TimerListener timerListener)141     void addTimerListener(TimerListener timerListener) {
142         mTimerListeners.add(timerListener);
143     }
144 
145     /**
146      * @param timerListener to no longer be notified when timers are added, updated and removed
147      */
removeTimerListener(TimerListener timerListener)148     void removeTimerListener(TimerListener timerListener) {
149         mTimerListeners.remove(timerListener);
150     }
151 
152     /**
153      * @return all defined timers in their creation order
154      */
getTimers()155     List<Timer> getTimers() {
156         return Collections.unmodifiableList(getMutableTimers());
157     }
158 
159     /**
160      * @return all expired timers in their expiration order
161      */
getExpiredTimers()162     List<Timer> getExpiredTimers() {
163         return Collections.unmodifiableList(getMutableExpiredTimers());
164     }
165 
166     /**
167      * @param timerId identifies the timer to return
168      * @return the timer with the given {@code timerId}
169      */
getTimer(int timerId)170     Timer getTimer(int timerId) {
171         for (Timer timer : getMutableTimers()) {
172             if (timer.getId() == timerId) {
173                 return timer;
174             }
175         }
176 
177         return null;
178     }
179 
180     /**
181      * @return the timer that last expired and is still expired now; {@code null} if no timers are
182      *      expired
183      */
getMostRecentExpiredTimer()184     Timer getMostRecentExpiredTimer() {
185         final List<Timer> timers = getMutableExpiredTimers();
186         return timers.isEmpty() ? null : timers.get(timers.size() - 1);
187     }
188 
189     /**
190      * @param length the length of the timer in milliseconds
191      * @param label describes the purpose of the timer
192      * @param deleteAfterUse {@code true} indicates the timer should be deleted when it is reset
193      * @return the newly added timer
194      */
addTimer(long length, String label, boolean deleteAfterUse)195     Timer addTimer(long length, String label, boolean deleteAfterUse) {
196         // Create the timer instance.
197         Timer timer = new Timer(-1, RESET, length, length, Long.MIN_VALUE, length, label,
198                 deleteAfterUse);
199 
200         // Add the timer to permanent storage.
201         timer = TimerDAO.addTimer(mContext, timer);
202 
203         // Add the timer to the cache.
204         getMutableTimers().add(0, timer);
205 
206         // Update the timer notification.
207         updateNotification();
208         // Heads-Up notification is unaffected by this change
209 
210         // Notify listeners of the change.
211         for (TimerListener timerListener : mTimerListeners) {
212             timerListener.timerAdded(timer);
213         }
214 
215         return timer;
216     }
217 
218     /**
219      * @param service used to start foreground notifications related to expired timers
220      * @param timer the timer to be expired
221      */
expireTimer(Service service, Timer timer)222     void expireTimer(Service service, Timer timer) {
223         if (mService == null) {
224             // If this is the first expired timer, retain the service that will be used to start
225             // the heads-up notification in the foreground.
226             mService = service;
227         } else if (mService != service) {
228             // If this is not the first expired timer, the service should match the one given when
229             // the first timer expired.
230             LogUtils.wtf("Expected TimerServices to be identical");
231         }
232 
233         updateTimer(timer.expire());
234     }
235 
236     /**
237      * @param timer an updated timer to store
238      */
updateTimer(Timer timer)239     void updateTimer(Timer timer) {
240         final Timer before = doUpdateTimer(timer);
241 
242         // Update the notification after updating the timer data.
243         updateNotification();
244 
245         // If the timer started or stopped being expired, update the heads-up notification.
246         if (before.getState() != timer.getState()) {
247             if (before.isExpired() || timer.isExpired()) {
248                 updateHeadsUpNotification();
249             }
250         }
251     }
252 
253     /**
254      * @param timer an existing timer to be removed
255      */
removeTimer(Timer timer)256     void removeTimer(Timer timer) {
257         doRemoveTimer(timer);
258 
259         // Update the timer notifications after removing the timer data.
260         updateNotification();
261         if (timer.isExpired()) {
262             updateHeadsUpNotification();
263         }
264     }
265 
266     /**
267      * If the given {@code timer} is expired and marked for deletion after use then this method
268      * removes the the timer. The timer is otherwise transitioned to the reset state and continues
269      * to exist.
270      *
271      * @param timer the timer to be reset
272      * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
273      */
resetOrDeleteTimer(Timer timer, @StringRes int eventLabelId)274     void resetOrDeleteTimer(Timer timer, @StringRes int eventLabelId) {
275         doResetOrDeleteTimer(timer, eventLabelId);
276 
277         // Update the notification after updating the timer data.
278         updateNotification();
279 
280         // If the timer stopped being expired, update the heads-up notification.
281         if (timer.isExpired()) {
282             updateHeadsUpNotification();
283         }
284     }
285 
286     /**
287      * Reset all timers.
288      *
289      * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
290      */
resetTimers(@tringRes int eventLabelId)291     void resetTimers(@StringRes int eventLabelId) {
292         final List<Timer> timers = new ArrayList<>(getTimers());
293         for (Timer timer : timers) {
294             doResetOrDeleteTimer(timer, eventLabelId);
295         }
296 
297         // Update the notifications once after all timers are reset.
298         updateNotification();
299         updateHeadsUpNotification();
300     }
301 
302     /**
303      * Reset all expired timers.
304      *
305      * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
306      */
resetExpiredTimers(@tringRes int eventLabelId)307     void resetExpiredTimers(@StringRes int eventLabelId) {
308         final List<Timer> timers = new ArrayList<>(getTimers());
309         for (Timer timer : timers) {
310             if (timer.isExpired()) {
311                 doResetOrDeleteTimer(timer, eventLabelId);
312             }
313         }
314 
315         // Update the notifications once after all timers are updated.
316         updateNotification();
317         updateHeadsUpNotification();
318     }
319 
320     /**
321      * Reset all unexpired timers.
322      *
323      * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
324      */
resetUnexpiredTimers(@tringRes int eventLabelId)325     void resetUnexpiredTimers(@StringRes int eventLabelId) {
326         final List<Timer> timers = new ArrayList<>(getTimers());
327         for (Timer timer : timers) {
328             if (timer.isRunning() || timer.isPaused()) {
329                 doResetOrDeleteTimer(timer, eventLabelId);
330             }
331         }
332 
333         // Update the notification once after all timers are updated.
334         updateNotification();
335         // Heads-Up notification is unaffected by this change
336     }
337 
338     /**
339      * @return the uri of the default ringtone to play for all timers when no user selection exists
340      */
getDefaultTimerRingtoneUri()341     Uri getDefaultTimerRingtoneUri() {
342         return mSettingsModel.getDefaultTimerRingtoneUri();
343     }
344 
345     /**
346      * @return {@code true} iff the ringtone to play for all timers is the silent ringtone
347      */
isTimerRingtoneSilent()348     boolean isTimerRingtoneSilent() {
349         return Uri.EMPTY.equals(getTimerRingtoneUri());
350     }
351 
352     /**
353      * @return the uri of the ringtone to play for all timers
354      */
getTimerRingtoneUri()355     Uri getTimerRingtoneUri() {
356         if (mTimerRingtoneUri == null) {
357             mTimerRingtoneUri = mSettingsModel.getTimerRingtoneUri();
358         }
359 
360         return mTimerRingtoneUri;
361     }
362 
363     /**
364      * @return the title of the ringtone that is played for all timers
365      */
getTimerRingtoneTitle()366     String getTimerRingtoneTitle() {
367         if (mTimerRingtoneTitle == null) {
368             if (isTimerRingtoneSilent()) {
369                 // Special case: no ringtone has a title of "Silent".
370                 mTimerRingtoneTitle = mContext.getString(R.string.silent_timer_ringtone_title);
371             } else {
372                 final Uri defaultUri = getDefaultTimerRingtoneUri();
373                 final Uri uri = getTimerRingtoneUri();
374 
375                 if (defaultUri.equals(uri)) {
376                     // Special case: default ringtone has a title of "Timer Expired".
377                     mTimerRingtoneTitle = mContext.getString(R.string.default_timer_ringtone_title);
378                 } else {
379                     final Ringtone ringtone = RingtoneManager.getRingtone(mContext, uri);
380                     mTimerRingtoneTitle = ringtone.getTitle(mContext);
381                 }
382             }
383         }
384 
385         return mTimerRingtoneTitle;
386     }
387 
getMutableTimers()388     private List<Timer> getMutableTimers() {
389         if (mTimers == null) {
390             mTimers = TimerDAO.getTimers(mContext);
391             Collections.sort(mTimers, Timer.ID_COMPARATOR);
392         }
393 
394         return mTimers;
395     }
396 
getMutableExpiredTimers()397     private List<Timer> getMutableExpiredTimers() {
398         if (mExpiredTimers == null) {
399             mExpiredTimers = new ArrayList<>();
400 
401             for (Timer timer : getMutableTimers()) {
402                 if (timer.isExpired()) {
403                     mExpiredTimers.add(timer);
404                 }
405             }
406             Collections.sort(mExpiredTimers, Timer.EXPIRY_COMPARATOR);
407         }
408 
409         return mExpiredTimers;
410     }
411 
412     /**
413      * This method updates timer data without updating notifications. This is useful in bulk-update
414      * scenarios so the notifications are only rebuilt once.
415      *
416      * @param timer an updated timer to store
417      * @return the state of the timer prior to the update
418      */
doUpdateTimer(Timer timer)419     private Timer doUpdateTimer(Timer timer) {
420         // Retrieve the cached form of the timer.
421         final List<Timer> timers = getMutableTimers();
422         final int index = timers.indexOf(timer);
423         final Timer before = timers.get(index);
424 
425         // If no change occurred, ignore this update.
426         if (timer == before) {
427             return timer;
428         }
429 
430         // Update the timer in permanent storage.
431         TimerDAO.updateTimer(mContext, timer);
432 
433         // Update the timer in the cache.
434         final Timer oldTimer = timers.set(index, timer);
435 
436         // Clear the cache of expired timers if the timer changed to/from expired.
437         if (before.isExpired() || timer.isExpired()) {
438             mExpiredTimers = null;
439         }
440 
441         // Update the timer expiration callback.
442         updateAlarmManager();
443 
444         // Update the timer ringer.
445         updateRinger(before, timer);
446 
447         // Notify listeners of the change.
448         for (TimerListener timerListener : mTimerListeners) {
449             timerListener.timerUpdated(before, timer);
450         }
451 
452         return oldTimer;
453     }
454 
455     /**
456      * This method removes timer data without updating notifications. This is useful in bulk-remove
457      * scenarios so the notifications are only rebuilt once.
458      *
459      * @param timer an existing timer to be removed
460      */
doRemoveTimer(Timer timer)461     void doRemoveTimer(Timer timer) {
462         // Remove the timer from permanent storage.
463         TimerDAO.removeTimer(mContext, timer);
464 
465         // Remove the timer from the cache.
466         final List<Timer> timers = getMutableTimers();
467         final int index = timers.indexOf(timer);
468 
469         // If the timer cannot be located there is nothing to remove.
470         if (index == -1) {
471             return;
472         }
473 
474         timer = timers.remove(index);
475 
476         // Clear the cache of expired timers if a new expired timer was added.
477         if (timer.isExpired()) {
478             mExpiredTimers = null;
479         }
480 
481         // Update the timer expiration callback.
482         updateAlarmManager();
483 
484         // Update the timer ringer.
485         updateRinger(timer, null);
486 
487         // Notify listeners of the change.
488         for (TimerListener timerListener : mTimerListeners) {
489             timerListener.timerRemoved(timer);
490         }
491     }
492 
493     /**
494      * This method updates/removes timer data without updating notifications. This is useful in
495      * bulk-update scenarios so the notifications are only rebuilt once.
496      *
497      * If the given {@code timer} is expired and marked for deletion after use then this method
498      * removes the the timer. The timer is otherwise transitioned to the reset state and continues
499      * to exist.
500      *
501      * @param timer the timer to be reset
502      * @param eventLabelId the label of the timer event to send; 0 if no event should be sent
503      */
doResetOrDeleteTimer(Timer timer, @StringRes int eventLabelId)504     private void doResetOrDeleteTimer(Timer timer, @StringRes int eventLabelId) {
505         if (timer.isExpired() && timer.getDeleteAfterUse()) {
506             doRemoveTimer(timer);
507             if (eventLabelId != 0) {
508                 Events.sendTimerEvent(R.string.action_delete, eventLabelId);
509             }
510         } else if (!timer.isReset()) {
511             doUpdateTimer(timer.reset());
512             if (eventLabelId != 0) {
513                 Events.sendTimerEvent(R.string.action_reset, eventLabelId);
514             }
515         }
516     }
517 
518     /**
519      * Updates the callback given to this application from the {@link AlarmManager} that signals the
520      * expiration of the next timer. If no timers are currently set to expire (i.e. no running
521      * timers exist) then this method clears the expiration callback from AlarmManager.
522      */
updateAlarmManager()523     private void updateAlarmManager() {
524         // Locate the next firing timer if one exists.
525         Timer nextExpiringTimer = null;
526         for (Timer timer : getMutableTimers()) {
527             if (timer.isRunning()) {
528                 if (nextExpiringTimer == null) {
529                     nextExpiringTimer = timer;
530                 } else if (timer.getExpirationTime() < nextExpiringTimer.getExpirationTime()) {
531                     nextExpiringTimer = timer;
532                 }
533             }
534         }
535 
536         // Build the intent that signals the timer expiration.
537         final Intent intent = TimerService.createTimerExpiredIntent(mContext, nextExpiringTimer);
538 
539         if (nextExpiringTimer == null) {
540             // Cancel the existing timer expiration callback.
541             final PendingIntent pi = PendingIntent.getService(mContext,
542                     0, intent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_NO_CREATE);
543             if (pi != null) {
544                 mAlarmManager.cancel(pi);
545                 pi.cancel();
546             }
547         } else {
548             // Update the existing timer expiration callback.
549             final PendingIntent pi = PendingIntent.getService(mContext,
550                     0, intent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
551             schedulePendingIntent(nextExpiringTimer.getExpirationTime(), pi);
552         }
553     }
554 
555     /**
556      * Starts and stops the ringer for timers if the change to the timer demands it.
557      *
558      * @param before the state of the timer before the change; {@code null} indicates added
559      * @param after the state of the timer after the change; {@code null} indicates delete
560      */
updateRinger(Timer before, Timer after)561     private void updateRinger(Timer before, Timer after) {
562         // Retrieve the states before and after the change.
563         final Timer.State beforeState = before == null ? null : before.getState();
564         final Timer.State afterState = after == null ? null : after.getState();
565 
566         // If the timer state did not change, the ringer state is unchanged.
567         if (beforeState == afterState) {
568             return;
569         }
570 
571         // If the timer is the first to expire, start ringing.
572         if (afterState == EXPIRED && mRingingIds.add(after.getId()) && mRingingIds.size() == 1) {
573             AlarmAlertWakeLock.acquireScreenCpuWakeLock(mContext);
574             TimerKlaxon.start(mContext);
575         }
576 
577         // If the expired timer was the last to reset, stop ringing.
578         if (beforeState == EXPIRED && mRingingIds.remove(before.getId()) && mRingingIds.isEmpty()) {
579             TimerKlaxon.stop(mContext);
580             AlarmAlertWakeLock.releaseCpuLock();
581         }
582     }
583 
584     /**
585      * Updates the notification controlling unexpired timers. This notification is only displayed
586      * when the application is not open.
587      */
updateNotification()588     void updateNotification() {
589         // Notifications should be hidden if the app is open.
590         if (mNotificationModel.isApplicationInForeground()) {
591             mNotificationManager.cancel(mNotificationModel.getUnexpiredTimerNotificationId());
592             return;
593         }
594 
595         // Filter the timers to just include unexpired ones.
596         final List<Timer> unexpired = new ArrayList<>();
597         for (Timer timer : getMutableTimers()) {
598             if (timer.isRunning() || timer.isPaused()) {
599                 unexpired.add(timer);
600             }
601         }
602 
603         // If no unexpired timers exist, cancel the notification.
604         if (unexpired.isEmpty()) {
605             mNotificationManager.cancel(mNotificationModel.getUnexpiredTimerNotificationId());
606             return;
607         }
608 
609         // Sort the unexpired timers to locate the next one scheduled to expire.
610         Collections.sort(unexpired, Timer.EXPIRY_COMPARATOR);
611         final Timer timer = unexpired.get(0);
612         final long remainingTime = timer.getRemainingTime();
613 
614         // Generate some descriptive text, a title, and some actions based on timer states.
615         final String contentText;
616         final String contentTitle;
617         @DrawableRes int firstActionIconId, secondActionIconId = 0;
618         @StringRes int firstActionTitleId, secondActionTitleId = 0;
619         Intent firstActionIntent, secondActionIntent = null;
620 
621         if (unexpired.size() == 1) {
622             contentText = formatElapsedTimeUntilExpiry(remainingTime);
623 
624             if (timer.isRunning()) {
625                 // Single timer is running.
626                 if (TextUtils.isEmpty(timer.getLabel())) {
627                     contentTitle = mContext.getString(R.string.timer_notification_label);
628                 } else {
629                     contentTitle = timer.getLabel();
630                 }
631 
632                 firstActionIconId = R.drawable.ic_pause_24dp;
633                 firstActionTitleId = R.string.timer_pause;
634                 firstActionIntent = new Intent(mContext, TimerService.class)
635                         .setAction(HandleDeskClockApiCalls.ACTION_PAUSE_TIMER)
636                         .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId());
637 
638                 secondActionIconId = R.drawable.ic_add_24dp;
639                 secondActionTitleId = R.string.timer_plus_1_min;
640                 secondActionIntent = new Intent(mContext, TimerService.class)
641                         .setAction(HandleDeskClockApiCalls.ACTION_ADD_MINUTE_TIMER)
642                         .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId());
643             } else {
644                 // Single timer is paused.
645                 contentTitle = mContext.getString(R.string.timer_paused);
646 
647                 firstActionIconId = R.drawable.ic_start_24dp;
648                 firstActionTitleId = R.string.sw_resume_button;
649                 firstActionIntent = new Intent(mContext, TimerService.class)
650                         .setAction(HandleDeskClockApiCalls.ACTION_START_TIMER)
651                         .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId());
652 
653                 secondActionIconId = R.drawable.ic_reset_24dp;
654                 secondActionTitleId = R.string.sw_reset_button;
655                 secondActionIntent = new Intent(mContext, TimerService.class)
656                         .setAction(HandleDeskClockApiCalls.ACTION_RESET_TIMER)
657                         .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId());
658             }
659         } else {
660             if (timer.isRunning()) {
661                 // At least one timer is running.
662                 final String timeRemaining = formatElapsedTimeUntilExpiry(remainingTime);
663                 contentText = mContext.getString(R.string.next_timer_notif, timeRemaining);
664                 contentTitle = mContext.getString(R.string.timers_in_use, unexpired.size());
665             } else {
666                 // All timers are paused.
667                 contentText = mContext.getString(R.string.all_timers_stopped_notif);
668                 contentTitle = mContext.getString(R.string.timers_stopped, unexpired.size());
669             }
670 
671             firstActionIconId = R.drawable.ic_reset_24dp;
672             firstActionTitleId = R.string.timer_reset_all;
673             firstActionIntent = TimerService.createResetUnexpiredTimersIntent(mContext);
674         }
675 
676         // Intent to load the app and show the timer when the notification is tapped.
677         final Intent showApp = new Intent(mContext, HandleDeskClockApiCalls.class)
678                 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
679                 .setAction(HandleDeskClockApiCalls.ACTION_SHOW_TIMERS)
680                 .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId())
681                 .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, R.string.label_notification);
682 
683         final PendingIntent pendingShowApp = PendingIntent.getActivity(mContext, 0, showApp,
684                 PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
685 
686         final NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext)
687                 .setOngoing(true)
688                 .setLocalOnly(true)
689                 .setShowWhen(false)
690                 .setAutoCancel(false)
691                 .setContentText(contentText)
692                 .setContentTitle(contentTitle)
693                 .setContentIntent(pendingShowApp)
694                 .setSmallIcon(R.drawable.stat_notify_timer)
695                 .setPriority(NotificationCompat.PRIORITY_HIGH)
696                 .setCategory(NotificationCompat.CATEGORY_ALARM)
697                 .setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
698 
699         final PendingIntent firstAction = PendingIntent.getService(mContext, 0,
700                 firstActionIntent, PendingIntent.FLAG_UPDATE_CURRENT);
701         final String firstActionTitle = mContext.getString(firstActionTitleId);
702         builder.addAction(firstActionIconId, firstActionTitle, firstAction);
703 
704         if (secondActionIntent != null) {
705             final PendingIntent secondAction = PendingIntent.getService(mContext, 0,
706                     secondActionIntent, PendingIntent.FLAG_UPDATE_CURRENT);
707             final String secondActionTitle = mContext.getString(secondActionTitleId);
708             builder.addAction(secondActionIconId, secondActionTitle, secondAction);
709         }
710 
711         // Update the notification.
712         final Notification notification = builder.build();
713         final int notificationId = mNotificationModel.getUnexpiredTimerNotificationId();
714         mNotificationManager.notify(notificationId, notification);
715 
716         final Intent updateNotification = TimerService.createUpdateNotificationIntent(mContext);
717         if (timer.isRunning() && remainingTime > MINUTE_IN_MILLIS) {
718             // Schedule a callback to update the time-sensitive information of the running timer.
719             final PendingIntent pi = PendingIntent.getService(mContext, 0, updateNotification,
720                     PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT);
721 
722             final long nextMinuteChange = remainingTime % MINUTE_IN_MILLIS;
723             final long triggerTime = SystemClock.elapsedRealtime() + nextMinuteChange;
724 
725             schedulePendingIntent(triggerTime, pi);
726         } else {
727             // Cancel the update notification callback.
728             final PendingIntent pi = PendingIntent.getService(mContext, 0, updateNotification,
729                     PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_NO_CREATE);
730             if (pi != null) {
731                 mAlarmManager.cancel(pi);
732                 pi.cancel();
733             }
734         }
735     }
736 
737     /**
738      * Updates the heads-up notification controlling expired timers. This heads-up notification is
739      * displayed whether the application is open or not.
740      */
updateHeadsUpNotification()741     private void updateHeadsUpNotification() {
742         // Nothing can be done with the heads-up notification without a valid service reference.
743         if (mService == null) {
744             return;
745         }
746 
747         final List<Timer> expired = getExpiredTimers();
748 
749         // If no expired timers exist, stop the service (which cancels the foreground notification).
750         if (expired.isEmpty()) {
751             mService.stopSelf();
752             mService = null;
753             return;
754         }
755 
756         // Generate some descriptive text, a title, and an action name based on the timer count.
757         final int timerId;
758         final String contentText;
759         final String contentTitle;
760         final String resetActionTitle;
761         if (expired.size() > 1) {
762             timerId = -1;
763             contentText = mContext.getString(R.string.timer_multi_times_up, expired.size());
764             contentTitle = mContext.getString(R.string.timer_notification_label);
765             resetActionTitle = mContext.getString(R.string.timer_stop_all);
766         } else {
767             final Timer timer = expired.get(0);
768             timerId = timer.getId();
769             resetActionTitle = mContext.getString(R.string.timer_stop);
770             contentText = mContext.getString(R.string.timer_times_up);
771 
772             final String label = timer.getLabel();
773             if (TextUtils.isEmpty(label)) {
774                 contentTitle = mContext.getString(R.string.timer_notification_label);
775             } else {
776                 contentTitle = label;
777             }
778         }
779 
780         // Content intent shows the timer full screen when clicked.
781         final Intent content = new Intent(mContext, ExpiredTimersActivity.class);
782         final PendingIntent pendingContent = PendingIntent.getActivity(mContext, 0, content,
783                 PendingIntent.FLAG_UPDATE_CURRENT);
784 
785         // Full screen intent has flags so it is different than the content intent.
786         final Intent fullScreen = new Intent(mContext, ExpiredTimersActivity.class)
787                 .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_USER_ACTION);
788         final PendingIntent pendingFullScreen = PendingIntent.getActivity(mContext, 0, fullScreen,
789                 PendingIntent.FLAG_UPDATE_CURRENT);
790 
791         // First action intent is either reset single timer or reset all timers.
792         final Intent reset = TimerService.createResetExpiredTimersIntent(mContext);
793         final PendingIntent pendingReset = PendingIntent.getService(mContext, 0, reset,
794                 PendingIntent.FLAG_UPDATE_CURRENT);
795 
796         final NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext)
797                 .setWhen(0)
798                 .setOngoing(true)
799                 .setLocalOnly(true)
800                 .setAutoCancel(false)
801                 .setContentText(contentText)
802                 .setContentTitle(contentTitle)
803                 .setContentIntent(pendingContent)
804                 .setSmallIcon(R.drawable.stat_notify_timer)
805                 .setFullScreenIntent(pendingFullScreen, true)
806                 .setPriority(NotificationCompat.PRIORITY_MAX)
807                 .setDefaults(NotificationCompat.DEFAULT_LIGHTS)
808                 .addAction(R.drawable.ic_stop_24dp, resetActionTitle, pendingReset);
809 
810         // Add a second action if only a single timer is expired.
811         if (expired.size() == 1) {
812             // Second action intent adds a minute to a single timer.
813             final Intent addMinute = TimerService.createAddMinuteTimerIntent(mContext, timerId);
814             final PendingIntent pendingAddMinute = PendingIntent.getService(mContext, 0, addMinute,
815                     PendingIntent.FLAG_UPDATE_CURRENT);
816             final String addMinuteTitle = mContext.getString(R.string.timer_plus_1_min);
817             builder.addAction(R.drawable.ic_add_24dp, addMinuteTitle, pendingAddMinute);
818         }
819 
820         // Update the notification.
821         final Notification notification = builder.build();
822         final int notificationId = mNotificationModel.getExpiredTimerNotificationId();
823         mService.startForeground(notificationId, notification);
824     }
825 
826     /**
827      * Format "7 hours 52 minutes remaining"
828      */
829     @VisibleForTesting
formatElapsedTimeUntilExpiry(long remainingTime)830     String formatElapsedTimeUntilExpiry(long remainingTime) {
831         final int hours = (int) remainingTime / (int) HOUR_IN_MILLIS;
832         final int minutes = (int) remainingTime / ((int) MINUTE_IN_MILLIS) % 60;
833 
834         String minSeq = Utils.getNumberFormattedQuantityString(mContext, R.plurals.minutes, minutes);
835         String hourSeq = Utils.getNumberFormattedQuantityString(mContext, R.plurals.hours, hours);
836 
837         // The verb "remaining" may have to change tense for singular subjects in some languages.
838         final String verb = mContext.getString((minutes > 1 || hours > 1)
839                 ? R.string.timer_remaining_multiple
840                 : R.string.timer_remaining_single);
841 
842         final boolean showHours = hours > 0;
843         final boolean showMinutes = minutes > 0;
844 
845         int formatStringId;
846         if (showHours) {
847             if (showMinutes) {
848                 formatStringId = R.string.timer_notifications_hours_minutes;
849             } else {
850                 formatStringId = R.string.timer_notifications_hours;
851             }
852         } else if (showMinutes) {
853             formatStringId = R.string.timer_notifications_minutes;
854         } else {
855             formatStringId = R.string.timer_notifications_less_min;
856         }
857         return String.format(mContext.getString(formatStringId), hourSeq, minSeq, verb);
858     }
859 
schedulePendingIntent(long triggerTime, PendingIntent pi)860     private void schedulePendingIntent(long triggerTime, PendingIntent pi) {
861         if (Utils.isMOrLater()) {
862             // Make sure the timer fires when the device is in doze mode. The timer is not
863             // guaranteed to fire at the requested time. It may be delayed up to 15 minutes.
864             mAlarmManager.setExactAndAllowWhileIdle(ELAPSED_REALTIME_WAKEUP, triggerTime, pi);
865         } else {
866             mAlarmManager.setExact(ELAPSED_REALTIME_WAKEUP, triggerTime, pi);
867         }
868     }
869 
870     /**
871      * Update the stopwatch notification in response to a locale change.
872      */
873     private final class LocaleChangedReceiver extends BroadcastReceiver {
874         @Override
onReceive(Context context, Intent intent)875         public void onReceive(Context context, Intent intent) {
876             updateNotification();
877             updateHeadsUpNotification();
878         }
879     }
880 
881     /**
882      * This receiver is notified when shared preferences change. Cached information built on
883      * preferences must be cleared.
884      */
885     private final class PreferenceListener implements OnSharedPreferenceChangeListener {
886         @Override
onSharedPreferenceChanged(SharedPreferences prefs, String key)887         public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
888             switch (key) {
889                 case SettingsActivity.KEY_TIMER_RINGTONE:
890                     mTimerRingtoneUri = null;
891                     mTimerRingtoneTitle = null;
892                     break;
893             }
894         }
895     }
896 }