Home | History | Annotate | Download | only in data
      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 
    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      */
    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      */
    148     void removeTimerListener(TimerListener timerListener) {
    149         mTimerListeners.remove(timerListener);
    150     }
    151 
    152     /**
    153      * @return all defined timers in their creation order
    154      */
    155     List<Timer> getTimers() {
    156         return Collections.unmodifiableList(getMutableTimers());
    157     }
    158 
    159     /**
    160      * @return all expired timers in their expiration order
    161      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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 
    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 
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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
    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 
    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
    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
    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 }