1 /*
2  * Copyright (C) 2013 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 package com.android.deskclock.alarms;
17 
18 import static com.android.deskclock.NotificationUtils.ALARM_MISSED_NOTIFICATION_CHANNEL_ID;
19 import static com.android.deskclock.NotificationUtils.ALARM_SNOOZE_NOTIFICATION_CHANNEL_ID;
20 import static com.android.deskclock.NotificationUtils.ALARM_UPCOMING_NOTIFICATION_CHANNEL_ID;
21 import static com.android.deskclock.NotificationUtils.FIRING_NOTIFICATION_CHANNEL_ID;
22 
23 import android.annotation.TargetApi;
24 import android.app.Notification;
25 import android.app.NotificationChannel;
26 import android.app.NotificationManager;
27 import android.app.PendingIntent;
28 import android.app.Service;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.content.res.Resources;
32 import android.os.Build;
33 import android.service.notification.StatusBarNotification;
34 import androidx.core.app.NotificationCompat;
35 import androidx.core.app.NotificationManagerCompat;
36 import androidx.core.content.ContextCompat;
37 
38 import com.android.deskclock.AlarmClockFragment;
39 import com.android.deskclock.AlarmUtils;
40 import com.android.deskclock.DeskClock;
41 import com.android.deskclock.LogUtils;
42 import com.android.deskclock.NotificationUtils;
43 import com.android.deskclock.R;
44 import com.android.deskclock.Utils;
45 import com.android.deskclock.provider.Alarm;
46 import com.android.deskclock.provider.AlarmInstance;
47 
48 import java.text.DateFormat;
49 import java.text.SimpleDateFormat;
50 import java.util.Locale;
51 import java.util.Objects;
52 
53 final class AlarmNotifications {
54     static final String EXTRA_NOTIFICATION_ID = "extra_notification_id";
55 
56     /**
57      * Formats times such that chronological order and lexicographical order agree.
58      */
59     private static final DateFormat SORT_KEY_FORMAT =
60             new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US);
61 
62     /**
63      * This value is coordinated with group ids from
64      * {@link com.android.deskclock.data.NotificationModel}
65      */
66     private static final String UPCOMING_GROUP_KEY = "1";
67 
68     /**
69      * This value is coordinated with group ids from
70      * {@link com.android.deskclock.data.NotificationModel}
71      */
72     private static final String MISSED_GROUP_KEY = "4";
73 
74     /**
75      * This value is coordinated with notification ids from
76      * {@link com.android.deskclock.data.NotificationModel}
77      */
78     private static final int ALARM_GROUP_NOTIFICATION_ID = Integer.MAX_VALUE - 4;
79 
80     /**
81      * This value is coordinated with notification ids from
82      * {@link com.android.deskclock.data.NotificationModel}
83      */
84     private static final int ALARM_GROUP_MISSED_NOTIFICATION_ID = Integer.MAX_VALUE - 5;
85 
86     /**
87      * This value is coordinated with notification ids from
88      * {@link com.android.deskclock.data.NotificationModel}
89      */
90     private static final int ALARM_FIRING_NOTIFICATION_ID = Integer.MAX_VALUE - 7;
91 
showUpcomingNotification(Context context, AlarmInstance instance, boolean lowPriority)92     static synchronized void showUpcomingNotification(Context context,
93             AlarmInstance instance, boolean lowPriority) {
94         LogUtils.v("Displaying upcoming alarm notification for alarm instance: " + instance.mId +
95                 "low priority: " + lowPriority);
96 
97         NotificationCompat.Builder builder = new NotificationCompat.Builder(
98                  context, ALARM_UPCOMING_NOTIFICATION_CHANNEL_ID)
99                          .setShowWhen(false)
100                         .setContentTitle(context.getString(
101                                 R.string.alarm_alert_predismiss_title))
102                         .setContentText(AlarmUtils.getAlarmText(
103                                 context, instance, true /* includeLabel */))
104                         .setColor(ContextCompat.getColor(context, R.color.default_background))
105                         .setSmallIcon(R.drawable.stat_notify_alarm)
106                         .setAutoCancel(false)
107                         .setSortKey(createSortKey(instance))
108                         .setPriority(NotificationCompat.PRIORITY_LOW)
109                         .setCategory(NotificationCompat.CATEGORY_EVENT)
110                         .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
111                         .setLocalOnly(true);
112 
113         if (Utils.isNOrLater()) {
114             builder.setGroup(UPCOMING_GROUP_KEY);
115         }
116 
117         final int id = instance.hashCode();
118         if (lowPriority) {
119             // Setup up hide notification
120             Intent hideIntent = AlarmStateManager.createStateChangeIntent(context,
121                     AlarmStateManager.ALARM_DELETE_TAG, instance,
122                     AlarmInstance.HIDE_NOTIFICATION_STATE);
123 
124             builder.setDeleteIntent(PendingIntent.getService(context, id,
125                     hideIntent, PendingIntent.FLAG_UPDATE_CURRENT));
126         }
127 
128         // Setup up dismiss action
129         Intent dismissIntent = AlarmStateManager.createStateChangeIntent(context,
130                 AlarmStateManager.ALARM_DISMISS_TAG, instance, AlarmInstance.PREDISMISSED_STATE);
131         builder.addAction(R.drawable.ic_alarm_off_24dp,
132                 context.getString(R.string.alarm_alert_dismiss_text),
133                 PendingIntent.getService(context, id,
134                         dismissIntent, PendingIntent.FLAG_UPDATE_CURRENT));
135 
136         // Setup content action if instance is owned by alarm
137         Intent viewAlarmIntent = createViewAlarmIntent(context, instance);
138         builder.setContentIntent(PendingIntent.getActivity(context, id,
139                 viewAlarmIntent, PendingIntent.FLAG_UPDATE_CURRENT));
140 
141         NotificationManagerCompat nm = NotificationManagerCompat.from(context);
142         NotificationUtils.createChannel(context, ALARM_UPCOMING_NOTIFICATION_CHANNEL_ID);
143         final Notification notification = builder.build();
144         nm.notify(id, notification);
145         updateUpcomingAlarmGroupNotification(context, -1, notification);
146     }
147 
148     @TargetApi(Build.VERSION_CODES.N)
isGroupSummary(Notification n)149     private static boolean isGroupSummary(Notification n) {
150         return (n.flags & Notification.FLAG_GROUP_SUMMARY) == Notification.FLAG_GROUP_SUMMARY;
151     }
152 
153     /**
154      * Method which returns the first active notification for a given group. If a notification was
155      * just posted, provide it to make sure it is included as a potential result. If a notification
156      * was just canceled, provide the id so that it is not included as a potential result. These
157      * extra parameters are needed due to a race condition which exists in
158      * {@link NotificationManager#getActiveNotifications()}.
159      *
160      * @param context Context from which to grab the NotificationManager
161      * @param group The group key to query for notifications
162      * @param canceledNotificationId The id of the just-canceled notification (-1 if none)
163      * @param postedNotification The notification that was just posted
164      * @return The first active notification for the group
165      */
166     @TargetApi(Build.VERSION_CODES.N)
getFirstActiveNotification(Context context, String group, int canceledNotificationId, Notification postedNotification)167     private static Notification getFirstActiveNotification(Context context, String group,
168             int canceledNotificationId, Notification postedNotification) {
169         final NotificationManager nm =
170                 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
171         final StatusBarNotification[] notifications = nm.getActiveNotifications();
172         Notification firstActiveNotification = postedNotification;
173         for (StatusBarNotification statusBarNotification : notifications) {
174             final Notification n = statusBarNotification.getNotification();
175             if (!isGroupSummary(n)
176                     && group.equals(n.getGroup())
177                     && statusBarNotification.getId() != canceledNotificationId) {
178                 if (firstActiveNotification == null
179                         || n.getSortKey().compareTo(firstActiveNotification.getSortKey()) < 0) {
180                     firstActiveNotification = n;
181                 }
182             }
183         }
184         return firstActiveNotification;
185     }
186 
187     @TargetApi(Build.VERSION_CODES.N)
getActiveGroupSummaryNotification(Context context, String group)188     private static Notification getActiveGroupSummaryNotification(Context context, String group) {
189         final NotificationManager nm =
190                 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
191         final StatusBarNotification[] notifications = nm.getActiveNotifications();
192         for (StatusBarNotification statusBarNotification : notifications) {
193             final Notification n = statusBarNotification.getNotification();
194             if (isGroupSummary(n) && group.equals(n.getGroup())) {
195                 return n;
196             }
197         }
198         return null;
199     }
200 
updateUpcomingAlarmGroupNotification(Context context, int canceledNotificationId, Notification postedNotification)201     private static void updateUpcomingAlarmGroupNotification(Context context,
202             int canceledNotificationId, Notification postedNotification) {
203         if (!Utils.isNOrLater()) {
204             return;
205         }
206 
207         final NotificationManagerCompat nm = NotificationManagerCompat.from(context);
208         final Notification firstUpcoming = getFirstActiveNotification(context, UPCOMING_GROUP_KEY,
209                 canceledNotificationId, postedNotification);
210         if (firstUpcoming == null) {
211             nm.cancel(ALARM_GROUP_NOTIFICATION_ID);
212             return;
213         }
214 
215         Notification summary = getActiveGroupSummaryNotification(context, UPCOMING_GROUP_KEY);
216         if (summary == null
217                 || !Objects.equals(summary.contentIntent, firstUpcoming.contentIntent)) {
218             NotificationUtils.createChannel(context, ALARM_UPCOMING_NOTIFICATION_CHANNEL_ID);
219             summary = new NotificationCompat.Builder(context,
220                         ALARM_UPCOMING_NOTIFICATION_CHANNEL_ID)
221                     .setShowWhen(false)
222                     .setContentIntent(firstUpcoming.contentIntent)
223                     .setColor(ContextCompat.getColor(context, R.color.default_background))
224                     .setSmallIcon(R.drawable.stat_notify_alarm)
225                     .setGroup(UPCOMING_GROUP_KEY)
226                     .setGroupSummary(true)
227                     .setPriority(NotificationCompat.PRIORITY_LOW)
228                     .setCategory(NotificationCompat.CATEGORY_EVENT)
229                     .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
230                     .setLocalOnly(true)
231                     .build();
232             nm.notify(ALARM_GROUP_NOTIFICATION_ID, summary);
233         }
234     }
235 
updateMissedAlarmGroupNotification(Context context, int canceledNotificationId, Notification postedNotification)236     private static void updateMissedAlarmGroupNotification(Context context,
237             int canceledNotificationId, Notification postedNotification) {
238         if (!Utils.isNOrLater()) {
239             return;
240         }
241 
242         final NotificationManagerCompat nm = NotificationManagerCompat.from(context);
243         final Notification firstMissed = getFirstActiveNotification(context, MISSED_GROUP_KEY,
244                 canceledNotificationId, postedNotification);
245         if (firstMissed == null) {
246             nm.cancel(ALARM_GROUP_MISSED_NOTIFICATION_ID);
247             return;
248         }
249 
250         Notification summary = getActiveGroupSummaryNotification(context, MISSED_GROUP_KEY);
251         if (summary == null
252                 || !Objects.equals(summary.contentIntent, firstMissed.contentIntent)) {
253             NotificationUtils.createChannel(context, ALARM_MISSED_NOTIFICATION_CHANNEL_ID);
254             summary = new NotificationCompat.Builder(context, ALARM_MISSED_NOTIFICATION_CHANNEL_ID)
255                     .setShowWhen(false)
256                     .setContentIntent(firstMissed.contentIntent)
257                     .setColor(ContextCompat.getColor(context, R.color.default_background))
258                     .setSmallIcon(R.drawable.stat_notify_alarm)
259                     .setGroup(MISSED_GROUP_KEY)
260                     .setGroupSummary(true)
261                     .setPriority(NotificationCompat.PRIORITY_HIGH)
262                     .setCategory(NotificationCompat.CATEGORY_EVENT)
263                     .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
264                     .setLocalOnly(true)
265                     .build();
266             nm.notify(ALARM_GROUP_MISSED_NOTIFICATION_ID, summary);
267         }
268     }
269 
showSnoozeNotification(Context context, AlarmInstance instance)270     static synchronized void showSnoozeNotification(Context context,
271             AlarmInstance instance) {
272         LogUtils.v("Displaying snoozed notification for alarm instance: " + instance.mId);
273 
274         NotificationCompat.Builder builder = new NotificationCompat.Builder(
275                 context, ALARM_SNOOZE_NOTIFICATION_CHANNEL_ID)
276                         .setShowWhen(false)
277                         .setContentTitle(instance.getLabelOrDefault(context))
278                         .setContentText(context.getString(R.string.alarm_alert_snooze_until,
279                                 AlarmUtils.getFormattedTime(context, instance.getAlarmTime())))
280                         .setColor(ContextCompat.getColor(context, R.color.default_background))
281                         .setSmallIcon(R.drawable.stat_notify_alarm)
282                         .setAutoCancel(false)
283                         .setSortKey(createSortKey(instance))
284                         .setPriority(NotificationCompat.PRIORITY_LOW)
285                         .setCategory(NotificationCompat.CATEGORY_EVENT)
286                         .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
287                         .setLocalOnly(true);
288 
289         if (Utils.isNOrLater()) {
290             builder.setGroup(UPCOMING_GROUP_KEY);
291         }
292 
293         // Setup up dismiss action
294         Intent dismissIntent = AlarmStateManager.createStateChangeIntent(context,
295                 AlarmStateManager.ALARM_DISMISS_TAG, instance, AlarmInstance.DISMISSED_STATE);
296         final int id = instance.hashCode();
297         builder.addAction(R.drawable.ic_alarm_off_24dp,
298                 context.getString(R.string.alarm_alert_dismiss_text),
299                 PendingIntent.getService(context, id,
300                         dismissIntent, PendingIntent.FLAG_UPDATE_CURRENT));
301 
302         // Setup content action if instance is owned by alarm
303         Intent viewAlarmIntent = createViewAlarmIntent(context, instance);
304         builder.setContentIntent(PendingIntent.getActivity(context, id,
305                 viewAlarmIntent, PendingIntent.FLAG_UPDATE_CURRENT));
306 
307         NotificationManagerCompat nm = NotificationManagerCompat.from(context);
308         NotificationUtils.createChannel(context, ALARM_SNOOZE_NOTIFICATION_CHANNEL_ID);
309         final Notification notification = builder.build();
310         nm.notify(id, notification);
311         updateUpcomingAlarmGroupNotification(context, -1, notification);
312     }
313 
showMissedNotification(Context context, AlarmInstance instance)314     static synchronized void showMissedNotification(Context context,
315             AlarmInstance instance) {
316         LogUtils.v("Displaying missed notification for alarm instance: " + instance.mId);
317 
318         String label = instance.mLabel;
319         String alarmTime = AlarmUtils.getFormattedTime(context, instance.getAlarmTime());
320         NotificationCompat.Builder builder = new NotificationCompat.Builder(
321                 context, ALARM_MISSED_NOTIFICATION_CHANNEL_ID)
322                         .setShowWhen(false)
323                         .setContentTitle(context.getString(R.string.alarm_missed_title))
324                         .setContentText(instance.mLabel.isEmpty() ? alarmTime :
325                                 context.getString(R.string.alarm_missed_text, alarmTime, label))
326                         .setColor(ContextCompat.getColor(context, R.color.default_background))
327                         .setSortKey(createSortKey(instance))
328                         .setSmallIcon(R.drawable.stat_notify_alarm)
329                         .setPriority(NotificationCompat.PRIORITY_HIGH)
330                         .setCategory(NotificationCompat.CATEGORY_EVENT)
331                         .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
332                         .setLocalOnly(true);
333 
334         if (Utils.isNOrLater()) {
335             builder.setGroup(MISSED_GROUP_KEY);
336         }
337 
338         final int id = instance.hashCode();
339 
340         // Setup dismiss intent
341         Intent dismissIntent = AlarmStateManager.createStateChangeIntent(context,
342                 AlarmStateManager.ALARM_DISMISS_TAG, instance, AlarmInstance.DISMISSED_STATE);
343         builder.setDeleteIntent(PendingIntent.getService(context, id,
344                 dismissIntent, PendingIntent.FLAG_UPDATE_CURRENT));
345 
346         // Setup content intent
347         Intent showAndDismiss = AlarmInstance.createIntent(context, AlarmStateManager.class,
348                 instance.mId);
349         showAndDismiss.putExtra(EXTRA_NOTIFICATION_ID, id);
350         showAndDismiss.setAction(AlarmStateManager.SHOW_AND_DISMISS_ALARM_ACTION);
351         builder.setContentIntent(PendingIntent.getBroadcast(context, id,
352                 showAndDismiss, PendingIntent.FLAG_UPDATE_CURRENT));
353 
354         NotificationManagerCompat nm = NotificationManagerCompat.from(context);
355         NotificationUtils.createChannel(context, ALARM_MISSED_NOTIFICATION_CHANNEL_ID);
356         final Notification notification = builder.build();
357         nm.notify(id, notification);
358         updateMissedAlarmGroupNotification(context, -1, notification);
359     }
360 
showAlarmNotification(Service service, AlarmInstance instance)361     static synchronized void showAlarmNotification(Service service, AlarmInstance instance) {
362         LogUtils.v("Displaying alarm notification for alarm instance: " + instance.mId);
363 
364         Resources resources = service.getResources();
365         NotificationCompat.Builder notification = new NotificationCompat.Builder(
366                 service, FIRING_NOTIFICATION_CHANNEL_ID)
367                         .setContentTitle(instance.getLabelOrDefault(service))
368                         .setContentText(AlarmUtils.getFormattedTime(
369                                 service, instance.getAlarmTime()))
370                         .setColor(ContextCompat.getColor(service, R.color.default_background))
371                         .setSmallIcon(R.drawable.stat_notify_alarm)
372                         .setOngoing(true)
373                         .setAutoCancel(false)
374                         .setDefaults(NotificationCompat.DEFAULT_LIGHTS)
375                         .setWhen(0)
376                         .setCategory(NotificationCompat.CATEGORY_ALARM)
377                         .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
378                         .setLocalOnly(true);
379 
380         // Setup Snooze Action
381         Intent snoozeIntent = AlarmStateManager.createStateChangeIntent(service,
382                 AlarmStateManager.ALARM_SNOOZE_TAG, instance, AlarmInstance.SNOOZE_STATE);
383         snoozeIntent.putExtra(AlarmStateManager.FROM_NOTIFICATION_EXTRA, true);
384         PendingIntent snoozePendingIntent = PendingIntent.getService(service,
385                 ALARM_FIRING_NOTIFICATION_ID, snoozeIntent, PendingIntent.FLAG_UPDATE_CURRENT);
386         notification.addAction(R.drawable.ic_snooze_24dp,
387                 resources.getString(R.string.alarm_alert_snooze_text), snoozePendingIntent);
388 
389         // Setup Dismiss Action
390         Intent dismissIntent = AlarmStateManager.createStateChangeIntent(service,
391                 AlarmStateManager.ALARM_DISMISS_TAG, instance, AlarmInstance.DISMISSED_STATE);
392         dismissIntent.putExtra(AlarmStateManager.FROM_NOTIFICATION_EXTRA, true);
393         PendingIntent dismissPendingIntent = PendingIntent.getService(service,
394                 ALARM_FIRING_NOTIFICATION_ID, dismissIntent, PendingIntent.FLAG_UPDATE_CURRENT);
395         notification.addAction(R.drawable.ic_alarm_off_24dp,
396                 resources.getString(R.string.alarm_alert_dismiss_text),
397                 dismissPendingIntent);
398 
399         // Setup Content Action
400         Intent contentIntent = AlarmInstance.createIntent(service, AlarmActivity.class,
401                 instance.mId);
402         notification.setContentIntent(PendingIntent.getActivity(service,
403                 ALARM_FIRING_NOTIFICATION_ID, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT));
404 
405         // Setup fullscreen intent
406         Intent fullScreenIntent = AlarmInstance.createIntent(service, AlarmActivity.class,
407                 instance.mId);
408         // set action, so we can be different then content pending intent
409         fullScreenIntent.setAction("fullscreen_activity");
410         fullScreenIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
411                 Intent.FLAG_ACTIVITY_NO_USER_ACTION);
412         notification.setFullScreenIntent(PendingIntent.getActivity(service,
413                 ALARM_FIRING_NOTIFICATION_ID, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT),
414                 true);
415         notification.setPriority(NotificationCompat.PRIORITY_HIGH);
416 
417         NotificationUtils.createChannel(service, FIRING_NOTIFICATION_CHANNEL_ID);
418         clearNotification(service, instance);
419         service.startForeground(ALARM_FIRING_NOTIFICATION_ID, notification.build());
420     }
421 
clearNotification(Context context, AlarmInstance instance)422     static synchronized void clearNotification(Context context, AlarmInstance instance) {
423         LogUtils.v("Clearing notifications for alarm instance: " + instance.mId);
424         NotificationManagerCompat nm = NotificationManagerCompat.from(context);
425         final int id = instance.hashCode();
426         nm.cancel(id);
427         updateUpcomingAlarmGroupNotification(context, id, null);
428         updateMissedAlarmGroupNotification(context, id, null);
429     }
430 
431     /**
432      * Updates the notification for an existing alarm. Use if the label has changed.
433      */
updateNotification(Context context, AlarmInstance instance)434     static void updateNotification(Context context, AlarmInstance instance) {
435         switch (instance.mAlarmState) {
436             case AlarmInstance.LOW_NOTIFICATION_STATE:
437                 showUpcomingNotification(context, instance, true);
438                 break;
439             case AlarmInstance.HIGH_NOTIFICATION_STATE:
440                 showUpcomingNotification(context, instance, false);
441                 break;
442             case AlarmInstance.SNOOZE_STATE:
443                 showSnoozeNotification(context, instance);
444                 break;
445             case AlarmInstance.MISSED_STATE:
446                 showMissedNotification(context, instance);
447                 break;
448             default:
449                 LogUtils.d("No notification to update");
450         }
451     }
452 
createViewAlarmIntent(Context context, AlarmInstance instance)453     static Intent createViewAlarmIntent(Context context, AlarmInstance instance) {
454         final long alarmId = instance.mAlarmId == null ? Alarm.INVALID_ID : instance.mAlarmId;
455         return Alarm.createIntent(context, DeskClock.class, alarmId)
456                 .putExtra(AlarmClockFragment.SCROLL_TO_ALARM_INTENT_EXTRA, alarmId)
457                 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
458     }
459 
460     /**
461      * Alarm notifications are sorted chronologically. Missed alarms are sorted chronologically
462      * <strong>after</strong> all upcoming/snoozed alarms by including the "MISSED" prefix on the
463      * sort key.
464      *
465      * @param instance the alarm instance for which the notification is generated
466      * @return the sort key that specifies the order of this alarm notification
467      */
createSortKey(AlarmInstance instance)468     private static String createSortKey(AlarmInstance instance) {
469         final String timeKey = SORT_KEY_FORMAT.format(instance.getAlarmTime().getTime());
470         final boolean missedAlarm = instance.mAlarmState == AlarmInstance.MISSED_STATE;
471         return missedAlarm ? ("MISSED " + timeKey) : timeKey;
472     }
473 }
474