| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.deskclock.data; |
| |
| import android.app.AlarmManager; |
| import android.app.Notification; |
| import android.app.PendingIntent; |
| import android.app.Service; |
| import android.content.BroadcastReceiver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.SharedPreferences; |
| import android.content.SharedPreferences.OnSharedPreferenceChangeListener; |
| import android.media.Ringtone; |
| import android.media.RingtoneManager; |
| import android.net.Uri; |
| import android.os.SystemClock; |
| import android.preference.PreferenceManager; |
| import android.support.annotation.DrawableRes; |
| import android.support.annotation.StringRes; |
| import android.support.annotation.VisibleForTesting; |
| import android.support.v4.app.NotificationCompat; |
| import android.support.v4.app.NotificationManagerCompat; |
| import android.text.TextUtils; |
| import android.util.ArraySet; |
| |
| import com.android.deskclock.AlarmAlertWakeLock; |
| import com.android.deskclock.HandleDeskClockApiCalls; |
| import com.android.deskclock.LogUtils; |
| import com.android.deskclock.R; |
| import com.android.deskclock.Utils; |
| import com.android.deskclock.events.Events; |
| import com.android.deskclock.settings.SettingsActivity; |
| import com.android.deskclock.timer.ExpiredTimersActivity; |
| import com.android.deskclock.timer.TimerKlaxon; |
| import com.android.deskclock.timer.TimerService; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.Set; |
| |
| import static android.app.AlarmManager.ELAPSED_REALTIME_WAKEUP; |
| import static android.text.format.DateUtils.HOUR_IN_MILLIS; |
| import static android.text.format.DateUtils.MINUTE_IN_MILLIS; |
| import static com.android.deskclock.data.Timer.State.EXPIRED; |
| import static com.android.deskclock.data.Timer.State.RESET; |
| |
| /** |
| * All {@link Timer} data is accessed via this model. |
| */ |
| final class TimerModel { |
| |
| private final Context mContext; |
| |
| /** The alarm manager system service that calls back when timers expire. */ |
| private final AlarmManager mAlarmManager; |
| |
| /** The model from which settings are fetched. */ |
| private final SettingsModel mSettingsModel; |
| |
| /** The model from which notification data are fetched. */ |
| private final NotificationModel mNotificationModel; |
| |
| /** Used to create and destroy system notifications related to timers. */ |
| private final NotificationManagerCompat mNotificationManager; |
| |
| /** Update timer notification when locale changes. */ |
| private final BroadcastReceiver mLocaleChangedReceiver = new LocaleChangedReceiver(); |
| |
| /** |
| * Retain a hard reference to the shared preference observer to prevent it from being garbage |
| * collected. See {@link SharedPreferences#registerOnSharedPreferenceChangeListener} for detail. |
| */ |
| private final OnSharedPreferenceChangeListener mPreferenceListener = new PreferenceListener(); |
| |
| /** The listeners to notify when a timer is added, updated or removed. */ |
| private final List<TimerListener> mTimerListeners = new ArrayList<>(); |
| |
| /** |
| * The ids of expired timers for which the ringer is ringing. Not all expired timers have their |
| * ids in this collection. If a timer was already expired when the app was started its id will |
| * be absent from this collection. |
| */ |
| private final Set<Integer> mRingingIds = new ArraySet<>(); |
| |
| /** The uri of the ringtone to play for timers. */ |
| private Uri mTimerRingtoneUri; |
| |
| /** The title of the ringtone to play for timers. */ |
| private String mTimerRingtoneTitle; |
| |
| /** A mutable copy of the timers. */ |
| private List<Timer> mTimers; |
| |
| /** A mutable copy of the expired timers. */ |
| private List<Timer> mExpiredTimers; |
| |
| /** |
| * The service that keeps this application in the foreground while a heads-up timer |
| * notification is displayed. Marking the service as foreground prevents the operating system |
| * from killing this application while expired timers are actively firing. |
| */ |
| private Service mService; |
| |
| TimerModel(Context context, SettingsModel settingsModel, NotificationModel notificationModel) { |
| mContext = context; |
| mSettingsModel = settingsModel; |
| mNotificationModel = notificationModel; |
| mNotificationManager = NotificationManagerCompat.from(context); |
| |
| mAlarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); |
| |
| // Clear caches affected by preferences when preferences change. |
| final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mContext); |
| prefs.registerOnSharedPreferenceChangeListener(mPreferenceListener); |
| |
| // Update stopwatch notification when locale changes. |
| final IntentFilter localeBroadcastFilter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); |
| mContext.registerReceiver(mLocaleChangedReceiver, localeBroadcastFilter); |
| } |
| |
| /** |
| * @param timerListener to be notified when timers are added, updated and removed |
| */ |
| void addTimerListener(TimerListener timerListener) { |
| mTimerListeners.add(timerListener); |
| } |
| |
| /** |
| * @param timerListener to no longer be notified when timers are added, updated and removed |
| */ |
| void removeTimerListener(TimerListener timerListener) { |
| mTimerListeners.remove(timerListener); |
| } |
| |
| /** |
| * @return all defined timers in their creation order |
| */ |
| List<Timer> getTimers() { |
| return Collections.unmodifiableList(getMutableTimers()); |
| } |
| |
| /** |
| * @return all expired timers in their expiration order |
| */ |
| List<Timer> getExpiredTimers() { |
| return Collections.unmodifiableList(getMutableExpiredTimers()); |
| } |
| |
| /** |
| * @param timerId identifies the timer to return |
| * @return the timer with the given {@code timerId} |
| */ |
| Timer getTimer(int timerId) { |
| for (Timer timer : getMutableTimers()) { |
| if (timer.getId() == timerId) { |
| return timer; |
| } |
| } |
| |
| return null; |
| } |
| |
| /** |
| * @return the timer that last expired and is still expired now; {@code null} if no timers are |
| * expired |
| */ |
| Timer getMostRecentExpiredTimer() { |
| final List<Timer> timers = getMutableExpiredTimers(); |
| return timers.isEmpty() ? null : timers.get(timers.size() - 1); |
| } |
| |
| /** |
| * @param length the length of the timer in milliseconds |
| * @param label describes the purpose of the timer |
| * @param deleteAfterUse {@code true} indicates the timer should be deleted when it is reset |
| * @return the newly added timer |
| */ |
| Timer addTimer(long length, String label, boolean deleteAfterUse) { |
| // Create the timer instance. |
| Timer timer = new Timer(-1, RESET, length, length, Long.MIN_VALUE, length, label, |
| deleteAfterUse); |
| |
| // Add the timer to permanent storage. |
| timer = TimerDAO.addTimer(mContext, timer); |
| |
| // Add the timer to the cache. |
| getMutableTimers().add(0, timer); |
| |
| // Update the timer notification. |
| updateNotification(); |
| // Heads-Up notification is unaffected by this change |
| |
| // Notify listeners of the change. |
| for (TimerListener timerListener : mTimerListeners) { |
| timerListener.timerAdded(timer); |
| } |
| |
| return timer; |
| } |
| |
| /** |
| * @param service used to start foreground notifications related to expired timers |
| * @param timer the timer to be expired |
| */ |
| void expireTimer(Service service, Timer timer) { |
| if (mService == null) { |
| // If this is the first expired timer, retain the service that will be used to start |
| // the heads-up notification in the foreground. |
| mService = service; |
| } else if (mService != service) { |
| // If this is not the first expired timer, the service should match the one given when |
| // the first timer expired. |
| LogUtils.wtf("Expected TimerServices to be identical"); |
| } |
| |
| updateTimer(timer.expire()); |
| } |
| |
| /** |
| * @param timer an updated timer to store |
| */ |
| void updateTimer(Timer timer) { |
| final Timer before = doUpdateTimer(timer); |
| |
| // Update the notification after updating the timer data. |
| updateNotification(); |
| |
| // If the timer started or stopped being expired, update the heads-up notification. |
| if (before.getState() != timer.getState()) { |
| if (before.isExpired() || timer.isExpired()) { |
| updateHeadsUpNotification(); |
| } |
| } |
| } |
| |
| /** |
| * @param timer an existing timer to be removed |
| */ |
| void removeTimer(Timer timer) { |
| doRemoveTimer(timer); |
| |
| // Update the timer notifications after removing the timer data. |
| updateNotification(); |
| if (timer.isExpired()) { |
| updateHeadsUpNotification(); |
| } |
| } |
| |
| /** |
| * If the given {@code timer} is expired and marked for deletion after use then this method |
| * removes the the timer. The timer is otherwise transitioned to the reset state and continues |
| * to exist. |
| * |
| * @param timer the timer to be reset |
| * @param eventLabelId the label of the timer event to send; 0 if no event should be sent |
| * @return the reset {@code timer} or {@code null} if the timer was deleted |
| */ |
| Timer resetOrDeleteTimer(Timer timer, @StringRes int eventLabelId) { |
| final Timer result = doResetOrDeleteTimer(timer, eventLabelId); |
| |
| // Update the notification after updating the timer data. |
| updateNotification(); |
| |
| // If the timer stopped being expired, update the heads-up notification. |
| if (timer.isExpired()) { |
| updateHeadsUpNotification(); |
| } |
| |
| return result; |
| } |
| |
| /** |
| * Reset all timers. |
| * |
| * @param eventLabelId the label of the timer event to send; 0 if no event should be sent |
| */ |
| void resetTimers(@StringRes int eventLabelId) { |
| final List<Timer> timers = new ArrayList<>(getTimers()); |
| for (Timer timer : timers) { |
| doResetOrDeleteTimer(timer, eventLabelId); |
| } |
| |
| // Update the notifications once after all timers are reset. |
| updateNotification(); |
| updateHeadsUpNotification(); |
| } |
| |
| /** |
| * Reset all expired timers. |
| * |
| * @param eventLabelId the label of the timer event to send; 0 if no event should be sent |
| */ |
| void resetExpiredTimers(@StringRes int eventLabelId) { |
| final List<Timer> timers = new ArrayList<>(getTimers()); |
| for (Timer timer : timers) { |
| if (timer.isExpired()) { |
| doResetOrDeleteTimer(timer, eventLabelId); |
| } |
| } |
| |
| // Update the notifications once after all timers are updated. |
| updateNotification(); |
| updateHeadsUpNotification(); |
| } |
| |
| /** |
| * Reset all unexpired timers. |
| * |
| * @param eventLabelId the label of the timer event to send; 0 if no event should be sent |
| */ |
| void resetUnexpiredTimers(@StringRes int eventLabelId) { |
| final List<Timer> timers = new ArrayList<>(getTimers()); |
| for (Timer timer : timers) { |
| if (timer.isRunning() || timer.isPaused()) { |
| doResetOrDeleteTimer(timer, eventLabelId); |
| } |
| } |
| |
| // Update the notification once after all timers are updated. |
| updateNotification(); |
| // Heads-Up notification is unaffected by this change |
| } |
| |
| /** |
| * @return the uri of the default ringtone to play for all timers when no user selection exists |
| */ |
| Uri getDefaultTimerRingtoneUri() { |
| return mSettingsModel.getDefaultTimerRingtoneUri(); |
| } |
| |
| /** |
| * @return {@code true} iff the ringtone to play for all timers is the silent ringtone |
| */ |
| boolean isTimerRingtoneSilent() { |
| return Uri.EMPTY.equals(getTimerRingtoneUri()); |
| } |
| |
| /** |
| * @return the uri of the ringtone to play for all timers |
| */ |
| Uri getTimerRingtoneUri() { |
| if (mTimerRingtoneUri == null) { |
| mTimerRingtoneUri = mSettingsModel.getTimerRingtoneUri(); |
| } |
| |
| return mTimerRingtoneUri; |
| } |
| |
| /** |
| * @param uri the uri of the ringtone to play for all timers |
| */ |
| void setTimerRingtoneUri(Uri uri) { |
| mSettingsModel.setTimerRingtoneUri(uri); |
| } |
| |
| /** |
| * @return the title of the ringtone that is played for all timers |
| */ |
| String getTimerRingtoneTitle() { |
| if (mTimerRingtoneTitle == null) { |
| if (isTimerRingtoneSilent()) { |
| // Special case: no ringtone has a title of "Silent". |
| mTimerRingtoneTitle = mContext.getString(R.string.silent_timer_ringtone_title); |
| } else { |
| final Uri defaultUri = getDefaultTimerRingtoneUri(); |
| final Uri uri = getTimerRingtoneUri(); |
| |
| if (defaultUri.equals(uri)) { |
| // Special case: default ringtone has a title of "Timer Expired". |
| mTimerRingtoneTitle = mContext.getString(R.string.default_timer_ringtone_title); |
| } else { |
| final Ringtone ringtone = RingtoneManager.getRingtone(mContext, uri); |
| mTimerRingtoneTitle = ringtone.getTitle(mContext); |
| } |
| } |
| } |
| |
| return mTimerRingtoneTitle; |
| } |
| |
| /** |
| * @return whether vibration is enabled for timers. |
| */ |
| boolean getTimerVibrate() { |
| return mSettingsModel.getTimerVibrate(); |
| } |
| |
| /** |
| * @param enabled whether the |
| */ |
| void setTimerVibrate(boolean enabled) { |
| mSettingsModel.setTimerVibrate(enabled); |
| } |
| |
| private List<Timer> getMutableTimers() { |
| if (mTimers == null) { |
| mTimers = TimerDAO.getTimers(mContext); |
| Collections.sort(mTimers, Timer.ID_COMPARATOR); |
| } |
| |
| return mTimers; |
| } |
| |
| private List<Timer> getMutableExpiredTimers() { |
| if (mExpiredTimers == null) { |
| mExpiredTimers = new ArrayList<>(); |
| |
| for (Timer timer : getMutableTimers()) { |
| if (timer.isExpired()) { |
| mExpiredTimers.add(timer); |
| } |
| } |
| Collections.sort(mExpiredTimers, Timer.EXPIRY_COMPARATOR); |
| } |
| |
| return mExpiredTimers; |
| } |
| |
| /** |
| * This method updates timer data without updating notifications. This is useful in bulk-update |
| * scenarios so the notifications are only rebuilt once. |
| * |
| * @param timer an updated timer to store |
| * @return the state of the timer prior to the update |
| */ |
| private Timer doUpdateTimer(Timer timer) { |
| // Retrieve the cached form of the timer. |
| final List<Timer> timers = getMutableTimers(); |
| final int index = timers.indexOf(timer); |
| final Timer before = timers.get(index); |
| |
| // If no change occurred, ignore this update. |
| if (timer == before) { |
| return timer; |
| } |
| |
| // Update the timer in permanent storage. |
| TimerDAO.updateTimer(mContext, timer); |
| |
| // Update the timer in the cache. |
| final Timer oldTimer = timers.set(index, timer); |
| |
| // Clear the cache of expired timers if the timer changed to/from expired. |
| if (before.isExpired() || timer.isExpired()) { |
| mExpiredTimers = null; |
| } |
| |
| // Update the timer expiration callback. |
| updateAlarmManager(); |
| |
| // Update the timer ringer. |
| updateRinger(before, timer); |
| |
| // Notify listeners of the change. |
| for (TimerListener timerListener : mTimerListeners) { |
| timerListener.timerUpdated(before, timer); |
| } |
| |
| return oldTimer; |
| } |
| |
| /** |
| * This method removes timer data without updating notifications. This is useful in bulk-remove |
| * scenarios so the notifications are only rebuilt once. |
| * |
| * @param timer an existing timer to be removed |
| */ |
| void doRemoveTimer(Timer timer) { |
| // Remove the timer from permanent storage. |
| TimerDAO.removeTimer(mContext, timer); |
| |
| // Remove the timer from the cache. |
| final List<Timer> timers = getMutableTimers(); |
| final int index = timers.indexOf(timer); |
| |
| // If the timer cannot be located there is nothing to remove. |
| if (index == -1) { |
| return; |
| } |
| |
| timer = timers.remove(index); |
| |
| // Clear the cache of expired timers if a new expired timer was added. |
| if (timer.isExpired()) { |
| mExpiredTimers = null; |
| } |
| |
| // Update the timer expiration callback. |
| updateAlarmManager(); |
| |
| // Update the timer ringer. |
| updateRinger(timer, null); |
| |
| // Notify listeners of the change. |
| for (TimerListener timerListener : mTimerListeners) { |
| timerListener.timerRemoved(timer); |
| } |
| } |
| |
| /** |
| * This method updates/removes timer data without updating notifications. This is useful in |
| * bulk-update scenarios so the notifications are only rebuilt once. |
| * |
| * If the given {@code timer} is expired and marked for deletion after use then this method |
| * removes the the timer. The timer is otherwise transitioned to the reset state and continues |
| * to exist. |
| * |
| * @param timer the timer to be reset |
| * @param eventLabelId the label of the timer event to send; 0 if no event should be sent |
| * @return the reset {@code timer} or {@code null} if the timer was deleted |
| */ |
| private Timer doResetOrDeleteTimer(Timer timer, @StringRes int eventLabelId) { |
| if (timer.isExpired() && timer.getDeleteAfterUse()) { |
| doRemoveTimer(timer); |
| if (eventLabelId != 0) { |
| Events.sendTimerEvent(R.string.action_delete, eventLabelId); |
| } |
| return null; |
| } else if (!timer.isReset()) { |
| final Timer reset = timer.reset(); |
| doUpdateTimer(reset); |
| if (eventLabelId != 0) { |
| Events.sendTimerEvent(R.string.action_reset, eventLabelId); |
| } |
| return reset; |
| } |
| |
| return timer; |
| } |
| |
| /** |
| * Updates the callback given to this application from the {@link AlarmManager} that signals the |
| * expiration of the next timer. If no timers are currently set to expire (i.e. no running |
| * timers exist) then this method clears the expiration callback from AlarmManager. |
| */ |
| private void updateAlarmManager() { |
| // Locate the next firing timer if one exists. |
| Timer nextExpiringTimer = null; |
| for (Timer timer : getMutableTimers()) { |
| if (timer.isRunning()) { |
| if (nextExpiringTimer == null) { |
| nextExpiringTimer = timer; |
| } else if (timer.getExpirationTime() < nextExpiringTimer.getExpirationTime()) { |
| nextExpiringTimer = timer; |
| } |
| } |
| } |
| |
| // Build the intent that signals the timer expiration. |
| final Intent intent = TimerService.createTimerExpiredIntent(mContext, nextExpiringTimer); |
| |
| if (nextExpiringTimer == null) { |
| // Cancel the existing timer expiration callback. |
| final PendingIntent pi = PendingIntent.getService(mContext, |
| 0, intent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_NO_CREATE); |
| if (pi != null) { |
| mAlarmManager.cancel(pi); |
| pi.cancel(); |
| } |
| } else { |
| // Update the existing timer expiration callback. |
| final PendingIntent pi = PendingIntent.getService(mContext, |
| 0, intent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT); |
| schedulePendingIntent(nextExpiringTimer.getExpirationTime(), pi); |
| } |
| } |
| |
| /** |
| * Starts and stops the ringer for timers if the change to the timer demands it. |
| * |
| * @param before the state of the timer before the change; {@code null} indicates added |
| * @param after the state of the timer after the change; {@code null} indicates delete |
| */ |
| private void updateRinger(Timer before, Timer after) { |
| // Retrieve the states before and after the change. |
| final Timer.State beforeState = before == null ? null : before.getState(); |
| final Timer.State afterState = after == null ? null : after.getState(); |
| |
| // If the timer state did not change, the ringer state is unchanged. |
| if (beforeState == afterState) { |
| return; |
| } |
| |
| // If the timer is the first to expire, start ringing. |
| if (afterState == EXPIRED && mRingingIds.add(after.getId()) && mRingingIds.size() == 1) { |
| AlarmAlertWakeLock.acquireScreenCpuWakeLock(mContext); |
| TimerKlaxon.start(mContext); |
| } |
| |
| // If the expired timer was the last to reset, stop ringing. |
| if (beforeState == EXPIRED && mRingingIds.remove(before.getId()) && mRingingIds.isEmpty()) { |
| TimerKlaxon.stop(mContext); |
| AlarmAlertWakeLock.releaseCpuLock(); |
| } |
| } |
| |
| /** |
| * Updates the notification controlling unexpired timers. This notification is only displayed |
| * when the application is not open. |
| */ |
| void updateNotification() { |
| // Notifications should be hidden if the app is open. |
| if (mNotificationModel.isApplicationInForeground()) { |
| mNotificationManager.cancel(mNotificationModel.getUnexpiredTimerNotificationId()); |
| return; |
| } |
| |
| // Filter the timers to just include unexpired ones. |
| final List<Timer> unexpired = new ArrayList<>(); |
| for (Timer timer : getMutableTimers()) { |
| if (timer.isRunning() || timer.isPaused()) { |
| unexpired.add(timer); |
| } |
| } |
| |
| // If no unexpired timers exist, cancel the notification. |
| if (unexpired.isEmpty()) { |
| mNotificationManager.cancel(mNotificationModel.getUnexpiredTimerNotificationId()); |
| return; |
| } |
| |
| // Sort the unexpired timers to locate the next one scheduled to expire. |
| Collections.sort(unexpired, Timer.EXPIRY_COMPARATOR); |
| final Timer timer = unexpired.get(0); |
| final long remainingTime = timer.getRemainingTime(); |
| |
| // Generate some descriptive text, a title, and some actions based on timer states. |
| final String contentText; |
| final String contentTitle; |
| @DrawableRes int firstActionIconId, secondActionIconId = 0; |
| @StringRes int firstActionTitleId, secondActionTitleId = 0; |
| Intent firstActionIntent, secondActionIntent = null; |
| |
| if (unexpired.size() == 1) { |
| contentText = formatElapsedTimeUntilExpiry(remainingTime); |
| |
| if (timer.isRunning()) { |
| // Single timer is running. |
| if (TextUtils.isEmpty(timer.getLabel())) { |
| contentTitle = mContext.getString(R.string.timer_notification_label); |
| } else { |
| contentTitle = timer.getLabel(); |
| } |
| |
| firstActionIconId = R.drawable.ic_pause_24dp; |
| firstActionTitleId = R.string.timer_pause; |
| firstActionIntent = new Intent(mContext, TimerService.class) |
| .setAction(HandleDeskClockApiCalls.ACTION_PAUSE_TIMER) |
| .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId()); |
| |
| secondActionIconId = R.drawable.ic_add_24dp; |
| secondActionTitleId = R.string.timer_plus_1_min; |
| secondActionIntent = new Intent(mContext, TimerService.class) |
| .setAction(HandleDeskClockApiCalls.ACTION_ADD_MINUTE_TIMER) |
| .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId()); |
| } else { |
| // Single timer is paused. |
| contentTitle = mContext.getString(R.string.timer_paused); |
| |
| firstActionIconId = R.drawable.ic_start_24dp; |
| firstActionTitleId = R.string.sw_resume_button; |
| firstActionIntent = new Intent(mContext, TimerService.class) |
| .setAction(HandleDeskClockApiCalls.ACTION_START_TIMER) |
| .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId()); |
| |
| secondActionIconId = R.drawable.ic_reset_24dp; |
| secondActionTitleId = R.string.sw_reset_button; |
| secondActionIntent = new Intent(mContext, TimerService.class) |
| .setAction(HandleDeskClockApiCalls.ACTION_RESET_TIMER) |
| .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId()); |
| } |
| } else { |
| if (timer.isRunning()) { |
| // At least one timer is running. |
| final String timeRemaining = formatElapsedTimeUntilExpiry(remainingTime); |
| contentText = mContext.getString(R.string.next_timer_notif, timeRemaining); |
| contentTitle = mContext.getString(R.string.timers_in_use, unexpired.size()); |
| } else { |
| // All timers are paused. |
| contentText = mContext.getString(R.string.all_timers_stopped_notif); |
| contentTitle = mContext.getString(R.string.timers_stopped, unexpired.size()); |
| } |
| |
| firstActionIconId = R.drawable.ic_reset_24dp; |
| firstActionTitleId = R.string.timer_reset_all; |
| firstActionIntent = TimerService.createResetUnexpiredTimersIntent(mContext); |
| } |
| |
| // Intent to load the app and show the timer when the notification is tapped. |
| final Intent showApp = new Intent(mContext, HandleDeskClockApiCalls.class) |
| .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
| .setAction(HandleDeskClockApiCalls.ACTION_SHOW_TIMERS) |
| .putExtra(HandleDeskClockApiCalls.EXTRA_TIMER_ID, timer.getId()) |
| .putExtra(HandleDeskClockApiCalls.EXTRA_EVENT_LABEL, R.string.label_notification); |
| |
| final PendingIntent pendingShowApp = PendingIntent.getActivity(mContext, 0, showApp, |
| PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT); |
| |
| final NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext) |
| .setOngoing(true) |
| .setLocalOnly(true) |
| .setShowWhen(false) |
| .setAutoCancel(false) |
| .setContentText(contentText) |
| .setContentTitle(contentTitle) |
| .setContentIntent(pendingShowApp) |
| .setSmallIcon(R.drawable.stat_notify_timer) |
| .setPriority(NotificationCompat.PRIORITY_HIGH) |
| .setCategory(NotificationCompat.CATEGORY_ALARM) |
| .setVisibility(NotificationCompat.VISIBILITY_PUBLIC); |
| |
| final PendingIntent firstAction = PendingIntent.getService(mContext, 0, |
| firstActionIntent, PendingIntent.FLAG_UPDATE_CURRENT); |
| final String firstActionTitle = mContext.getString(firstActionTitleId); |
| builder.addAction(firstActionIconId, firstActionTitle, firstAction); |
| |
| if (secondActionIntent != null) { |
| final PendingIntent secondAction = PendingIntent.getService(mContext, 0, |
| secondActionIntent, PendingIntent.FLAG_UPDATE_CURRENT); |
| final String secondActionTitle = mContext.getString(secondActionTitleId); |
| builder.addAction(secondActionIconId, secondActionTitle, secondAction); |
| } |
| |
| // Update the notification. |
| final Notification notification = builder.build(); |
| final int notificationId = mNotificationModel.getUnexpiredTimerNotificationId(); |
| mNotificationManager.notify(notificationId, notification); |
| |
| final Intent updateNotification = TimerService.createUpdateNotificationIntent(mContext); |
| if (timer.isRunning() && remainingTime > MINUTE_IN_MILLIS) { |
| // Schedule a callback to update the time-sensitive information of the running timer. |
| final PendingIntent pi = PendingIntent.getService(mContext, 0, updateNotification, |
| PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_UPDATE_CURRENT); |
| |
| final long nextMinuteChange = remainingTime % MINUTE_IN_MILLIS; |
| final long triggerTime = SystemClock.elapsedRealtime() + nextMinuteChange; |
| |
| schedulePendingIntent(triggerTime, pi); |
| } else { |
| // Cancel the update notification callback. |
| final PendingIntent pi = PendingIntent.getService(mContext, 0, updateNotification, |
| PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_NO_CREATE); |
| if (pi != null) { |
| mAlarmManager.cancel(pi); |
| pi.cancel(); |
| } |
| } |
| } |
| |
| /** |
| * Updates the heads-up notification controlling expired timers. This heads-up notification is |
| * displayed whether the application is open or not. |
| */ |
| private void updateHeadsUpNotification() { |
| // Nothing can be done with the heads-up notification without a valid service reference. |
| if (mService == null) { |
| return; |
| } |
| |
| final List<Timer> expired = getExpiredTimers(); |
| |
| // If no expired timers exist, stop the service (which cancels the foreground notification). |
| if (expired.isEmpty()) { |
| mService.stopSelf(); |
| mService = null; |
| return; |
| } |
| |
| // Generate some descriptive text, a title, and an action name based on the timer count. |
| final int timerId; |
| final String contentText; |
| final String contentTitle; |
| final String resetActionTitle; |
| if (expired.size() > 1) { |
| timerId = -1; |
| contentText = mContext.getString(R.string.timer_multi_times_up, expired.size()); |
| contentTitle = mContext.getString(R.string.timer_notification_label); |
| resetActionTitle = mContext.getString(R.string.timer_stop_all); |
| } else { |
| final Timer timer = expired.get(0); |
| timerId = timer.getId(); |
| resetActionTitle = mContext.getString(R.string.timer_stop); |
| contentText = mContext.getString(R.string.timer_times_up); |
| |
| final String label = timer.getLabel(); |
| if (TextUtils.isEmpty(label)) { |
| contentTitle = mContext.getString(R.string.timer_notification_label); |
| } else { |
| contentTitle = label; |
| } |
| } |
| |
| // Content intent shows the timer full screen when clicked. |
| final Intent content = new Intent(mContext, ExpiredTimersActivity.class); |
| final PendingIntent pendingContent = PendingIntent.getActivity(mContext, 0, content, |
| PendingIntent.FLAG_UPDATE_CURRENT); |
| |
| // Full screen intent has flags so it is different than the content intent. |
| final Intent fullScreen = new Intent(mContext, ExpiredTimersActivity.class) |
| .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_USER_ACTION); |
| final PendingIntent pendingFullScreen = PendingIntent.getActivity(mContext, 0, fullScreen, |
| PendingIntent.FLAG_UPDATE_CURRENT); |
| |
| // First action intent is either reset single timer or reset all timers. |
| final Intent reset = TimerService.createResetExpiredTimersIntent(mContext); |
| final PendingIntent pendingReset = PendingIntent.getService(mContext, 0, reset, |
| PendingIntent.FLAG_UPDATE_CURRENT); |
| |
| final NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext) |
| .setWhen(0) |
| .setOngoing(true) |
| .setLocalOnly(true) |
| .setAutoCancel(false) |
| .setContentText(contentText) |
| .setContentTitle(contentTitle) |
| .setContentIntent(pendingContent) |
| .setSmallIcon(R.drawable.stat_notify_timer) |
| .setFullScreenIntent(pendingFullScreen, true) |
| .setPriority(NotificationCompat.PRIORITY_MAX) |
| .setDefaults(NotificationCompat.DEFAULT_LIGHTS) |
| .addAction(R.drawable.ic_stop_24dp, resetActionTitle, pendingReset); |
| |
| // Add a second action if only a single timer is expired. |
| if (expired.size() == 1) { |
| // Second action intent adds a minute to a single timer. |
| final Intent addMinute = TimerService.createAddMinuteTimerIntent(mContext, timerId); |
| final PendingIntent pendingAddMinute = PendingIntent.getService(mContext, 0, addMinute, |
| PendingIntent.FLAG_UPDATE_CURRENT); |
| final String addMinuteTitle = mContext.getString(R.string.timer_plus_1_min); |
| builder.addAction(R.drawable.ic_add_24dp, addMinuteTitle, pendingAddMinute); |
| } |
| |
| // Update the notification. |
| final Notification notification = builder.build(); |
| final int notificationId = mNotificationModel.getExpiredTimerNotificationId(); |
| mService.startForeground(notificationId, notification); |
| } |
| |
| /** |
| * Format "7 hours 52 minutes remaining" |
| */ |
| @VisibleForTesting |
| String formatElapsedTimeUntilExpiry(long remainingTime) { |
| final int hours = (int) remainingTime / (int) HOUR_IN_MILLIS; |
| final int minutes = (int) remainingTime / ((int) MINUTE_IN_MILLIS) % 60; |
| |
| String minSeq = Utils.getNumberFormattedQuantityString(mContext, R.plurals.minutes, minutes); |
| String hourSeq = Utils.getNumberFormattedQuantityString(mContext, R.plurals.hours, hours); |
| |
| // The verb "remaining" may have to change tense for singular subjects in some languages. |
| final String verb = mContext.getString((minutes > 1 || hours > 1) |
| ? R.string.timer_remaining_multiple |
| : R.string.timer_remaining_single); |
| |
| final boolean showHours = hours > 0; |
| final boolean showMinutes = minutes > 0; |
| |
| int formatStringId; |
| if (showHours) { |
| if (showMinutes) { |
| formatStringId = R.string.timer_notifications_hours_minutes; |
| } else { |
| formatStringId = R.string.timer_notifications_hours; |
| } |
| } else if (showMinutes) { |
| formatStringId = R.string.timer_notifications_minutes; |
| } else { |
| formatStringId = R.string.timer_notifications_less_min; |
| } |
| return String.format(mContext.getString(formatStringId), hourSeq, minSeq, verb); |
| } |
| |
| private void schedulePendingIntent(long triggerTime, PendingIntent pi) { |
| if (Utils.isMOrLater()) { |
| // Ensure the timer fires even if the device is dozing. |
| mAlarmManager.setExactAndAllowWhileIdle(ELAPSED_REALTIME_WAKEUP, triggerTime, pi); |
| } else { |
| mAlarmManager.setExact(ELAPSED_REALTIME_WAKEUP, triggerTime, pi); |
| } |
| } |
| |
| /** |
| * Update the stopwatch notification in response to a locale change. |
| */ |
| private final class LocaleChangedReceiver extends BroadcastReceiver { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| updateNotification(); |
| updateHeadsUpNotification(); |
| } |
| } |
| |
| /** |
| * This receiver is notified when shared preferences change. Cached information built on |
| * preferences must be cleared. |
| */ |
| private final class PreferenceListener implements OnSharedPreferenceChangeListener { |
| @Override |
| public void onSharedPreferenceChanged(SharedPreferences prefs, String key) { |
| switch (key) { |
| case SettingsActivity.KEY_TIMER_RINGTONE: |
| mTimerRingtoneUri = null; |
| mTimerRingtoneTitle = null; |
| break; |
| } |
| } |
| } |
| } |