1 /*
2  * Copyright (C) 2012 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.calendar.alerts;
18 
19 import android.app.AlarmManager;
20 import android.app.PendingIntent;
21 import android.content.ContentResolver;
22 import android.content.ContentUris;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.database.Cursor;
26 import android.net.Uri;
27 import android.provider.CalendarContract;
28 import android.provider.CalendarContract.Events;
29 import android.provider.CalendarContract.Instances;
30 import android.provider.CalendarContract.Reminders;
31 import android.text.format.DateUtils;
32 import android.text.format.Time;
33 import android.util.Log;
34 
35 import com.android.calendar.Utils;
36 
37 import java.util.ArrayList;
38 import java.util.HashMap;
39 import java.util.List;
40 import java.util.Map;
41 
42 /**
43  * Schedules the next EVENT_REMINDER_APP broadcast with AlarmManager, by querying the events
44  * and reminders tables for the next upcoming alert.
45  */
46 public class AlarmScheduler {
47     private static final String TAG = "AlarmScheduler";
48 
49     private static final String INSTANCES_WHERE = Events.VISIBLE + "=? AND "
50             + Instances.BEGIN + ">=? AND " + Instances.BEGIN + "<=? AND "
51             + Events.ALL_DAY + "=?";
52     static final String[] INSTANCES_PROJECTION = new String[] {
53         Instances.EVENT_ID,
54         Instances.BEGIN,
55         Instances.ALL_DAY,
56     };
57     private static final int INSTANCES_INDEX_EVENTID = 0;
58     private static final int INSTANCES_INDEX_BEGIN = 1;
59     private static final int INSTANCES_INDEX_ALL_DAY = 2;
60 
61     private static final String REMINDERS_WHERE = Reminders.METHOD + "=1 AND "
62             + Reminders.EVENT_ID + " IN ";
63     static final String[] REMINDERS_PROJECTION = new String[] {
64         Reminders.EVENT_ID,
65         Reminders.MINUTES,
66         Reminders.METHOD,
67     };
68     private static final int REMINDERS_INDEX_EVENT_ID = 0;
69     private static final int REMINDERS_INDEX_MINUTES = 1;
70     private static final int REMINDERS_INDEX_METHOD = 2;
71 
72     // Add a slight delay for the EVENT_REMINDER_APP broadcast for a couple reasons:
73     // (1) so that the concurrent reminder broadcast from the provider doesn't result
74     // in a double ring, and (2) some OEMs modified the provider to not add an alert to
75     // the CalendarAlerts table until the alert time, so for the unbundled app's
76     // notifications to work on these devices, a delay ensures that AlertService won't
77     // read from the CalendarAlerts table until the alert is present.
78     static final int ALARM_DELAY_MS = 1000;
79 
80     // The reminders query looks like "SELECT ... AND eventId IN 101,102,202,...".  This
81     // sets the max # of events in the query before batching into multiple queries, to
82     // limit the SQL query length.
83     private static final int REMINDER_QUERY_BATCH_SIZE = 50;
84 
85     // We really need to query for reminder times that fall in some interval, but
86     // the Reminders table only stores the reminder interval (10min, 15min, etc), and
87     // we cannot do the join with the Events table to calculate the actual alert time
88     // from outside of the provider.  So the best we can do for now consider events
89     // whose start times begin within some interval (ie. 1 week out).  This means
90     // reminders which are configured for more than 1 week out won't fire on time.  We
91     // can minimize this to being only 1 day late by putting a 1 day max on the alarm time.
92     private static final long EVENT_LOOKAHEAD_WINDOW_MS = DateUtils.WEEK_IN_MILLIS;
93     private static final long MAX_ALARM_ELAPSED_MS = DateUtils.DAY_IN_MILLIS;
94 
95     /**
96      * Schedules the nearest upcoming alarm, to refresh notifications.
97      *
98      * This is historically done in the provider but we dupe this here so the unbundled
99      * app will work on devices that have modified this portion of the provider.  This
100      * has the limitation of querying events within some interval from now (ie. looks at
101      * reminders for all events occurring in the next week).  This means for example,
102      * a 2 week notification will not fire on time.
103      */
scheduleNextAlarm(Context context)104     public static void scheduleNextAlarm(Context context) {
105         scheduleNextAlarm(context, AlertUtils.createAlarmManager(context),
106                 REMINDER_QUERY_BATCH_SIZE, System.currentTimeMillis());
107     }
108 
109     // VisibleForTesting
scheduleNextAlarm(Context context, AlarmManagerInterface alarmManager, int batchSize, long currentMillis)110     static void scheduleNextAlarm(Context context, AlarmManagerInterface alarmManager,
111             int batchSize, long currentMillis) {
112         Cursor instancesCursor = null;
113         try {
114             instancesCursor = queryUpcomingEvents(context, context.getContentResolver(),
115                     currentMillis);
116             if (instancesCursor != null) {
117                 queryNextReminderAndSchedule(instancesCursor, context,
118                         context.getContentResolver(), alarmManager, batchSize, currentMillis);
119             }
120         } finally {
121             if (instancesCursor != null) {
122                 instancesCursor.close();
123             }
124         }
125     }
126 
127     /**
128      * Queries events starting within a fixed interval from now.
129      */
queryUpcomingEvents(Context context, ContentResolver contentResolver, long currentMillis)130     private static Cursor queryUpcomingEvents(Context context, ContentResolver contentResolver,
131             long currentMillis) {
132         Time time = new Time();
133         time.normalize(false);
134         long localOffset = time.gmtoff * 1000;
135         final long localStartMin = currentMillis;
136         final long localStartMax = localStartMin + EVENT_LOOKAHEAD_WINDOW_MS;
137         final long utcStartMin = localStartMin - localOffset;
138         final long utcStartMax = utcStartMin + EVENT_LOOKAHEAD_WINDOW_MS;
139 
140         // Expand Instances table range by a day on either end to account for
141         // all-day events.
142         Uri.Builder uriBuilder = Instances.CONTENT_URI.buildUpon();
143         ContentUris.appendId(uriBuilder, localStartMin - DateUtils.DAY_IN_MILLIS);
144         ContentUris.appendId(uriBuilder, localStartMax + DateUtils.DAY_IN_MILLIS);
145 
146         // Build query for all events starting within the fixed interval.
147         StringBuilder queryBuilder = new StringBuilder();
148         queryBuilder.append("(");
149         queryBuilder.append(INSTANCES_WHERE);
150         queryBuilder.append(") OR (");
151         queryBuilder.append(INSTANCES_WHERE);
152         queryBuilder.append(")");
153         String[] queryArgs = new String[] {
154                 // allday selection
155                 "1",                           /* visible = ? */
156                 String.valueOf(utcStartMin),   /* begin >= ? */
157                 String.valueOf(utcStartMax),   /* begin <= ? */
158                 "1",                           /* allDay = ? */
159 
160                 // non-allday selection
161                 "1",                           /* visible = ? */
162                 String.valueOf(localStartMin), /* begin >= ? */
163                 String.valueOf(localStartMax), /* begin <= ? */
164                 "0"                            /* allDay = ? */
165         };
166 
167         Cursor cursor = contentResolver.query(uriBuilder.build(), INSTANCES_PROJECTION,
168                 queryBuilder.toString(), queryArgs, null);
169         return cursor;
170     }
171 
172     /**
173      * Queries for all the reminders of the events in the instancesCursor, and schedules
174      * the alarm for the next upcoming reminder.
175      */
queryNextReminderAndSchedule(Cursor instancesCursor, Context context, ContentResolver contentResolver, AlarmManagerInterface alarmManager, int batchSize, long currentMillis)176     private static void queryNextReminderAndSchedule(Cursor instancesCursor, Context context,
177             ContentResolver contentResolver, AlarmManagerInterface alarmManager,
178             int batchSize, long currentMillis) {
179         if (AlertService.DEBUG) {
180             int eventCount = instancesCursor.getCount();
181             if (eventCount == 0) {
182                 Log.d(TAG, "No events found starting within 1 week.");
183             } else {
184                 Log.d(TAG, "Query result count for events starting within 1 week: " + eventCount);
185             }
186         }
187 
188         // Put query results of all events starting within some interval into map of event ID to
189         // local start time.
190         Map<Integer, List<Long>> eventMap = new HashMap<Integer, List<Long>>();
191         Time timeObj = new Time();
192         long nextAlarmTime = Long.MAX_VALUE;
193         int nextAlarmEventId = 0;
194         instancesCursor.moveToPosition(-1);
195         while (!instancesCursor.isAfterLast()) {
196             int index = 0;
197             eventMap.clear();
198             StringBuilder eventIdsForQuery = new StringBuilder();
199             eventIdsForQuery.append('(');
200             while (index++ < batchSize && instancesCursor.moveToNext()) {
201                 int eventId = instancesCursor.getInt(INSTANCES_INDEX_EVENTID);
202                 long begin = instancesCursor.getLong(INSTANCES_INDEX_BEGIN);
203                 boolean allday = instancesCursor.getInt(INSTANCES_INDEX_ALL_DAY) != 0;
204                 long localStartTime;
205                 if (allday) {
206                     // Adjust allday to local time.
207                     localStartTime = Utils.convertAlldayUtcToLocal(timeObj, begin,
208                             Time.getCurrentTimezone());
209                 } else {
210                     localStartTime = begin;
211                 }
212                 List<Long> startTimes = eventMap.get(eventId);
213                 if (startTimes == null) {
214                     startTimes = new ArrayList<Long>();
215                     eventMap.put(eventId, startTimes);
216                     eventIdsForQuery.append(eventId);
217                     eventIdsForQuery.append(",");
218                 }
219                 startTimes.add(localStartTime);
220 
221                 // Log for debugging.
222                 if (Log.isLoggable(TAG, Log.DEBUG)) {
223                     timeObj.set(localStartTime);
224                     StringBuilder msg = new StringBuilder();
225                     msg.append("Events cursor result -- eventId:").append(eventId);
226                     msg.append(", allDay:").append(allday);
227                     msg.append(", start:").append(localStartTime);
228                     msg.append(" (").append(timeObj.format("%a, %b %d, %Y %I:%M%P")).append(")");
229                     Log.d(TAG, msg.toString());
230                 }
231             }
232             if (eventIdsForQuery.charAt(eventIdsForQuery.length() - 1) == ',') {
233                 eventIdsForQuery.deleteCharAt(eventIdsForQuery.length() - 1);
234             }
235             eventIdsForQuery.append(')');
236 
237             // Query the reminders table for the events found.
238             Cursor cursor = null;
239             try {
240                 cursor = contentResolver.query(Reminders.CONTENT_URI, REMINDERS_PROJECTION,
241                         REMINDERS_WHERE + eventIdsForQuery, null, null);
242 
243                 // Process the reminders query results to find the next reminder time.
244                 cursor.moveToPosition(-1);
245                 while (cursor.moveToNext()) {
246                     int eventId = cursor.getInt(REMINDERS_INDEX_EVENT_ID);
247                     int reminderMinutes = cursor.getInt(REMINDERS_INDEX_MINUTES);
248                     List<Long> startTimes = eventMap.get(eventId);
249                     if (startTimes != null) {
250                         for (Long startTime : startTimes) {
251                             long alarmTime = startTime -
252                                     reminderMinutes * DateUtils.MINUTE_IN_MILLIS;
253                             if (alarmTime > currentMillis && alarmTime < nextAlarmTime) {
254                                 nextAlarmTime = alarmTime;
255                                 nextAlarmEventId = eventId;
256                             }
257 
258                             if (Log.isLoggable(TAG, Log.DEBUG)) {
259                                 timeObj.set(alarmTime);
260                                 StringBuilder msg = new StringBuilder();
261                                 msg.append("Reminders cursor result -- eventId:").append(eventId);
262                                 msg.append(", startTime:").append(startTime);
263                                 msg.append(", minutes:").append(reminderMinutes);
264                                 msg.append(", alarmTime:").append(alarmTime);
265                                 msg.append(" (").append(timeObj.format("%a, %b %d, %Y %I:%M%P"))
266                                         .append(")");
267                                 Log.d(TAG, msg.toString());
268                             }
269                         }
270                     }
271                 }
272             } finally {
273                 if (cursor != null) {
274                     cursor.close();
275                 }
276             }
277         }
278 
279         // Schedule the alarm for the next reminder time.
280         if (nextAlarmTime < Long.MAX_VALUE) {
281             scheduleAlarm(context, nextAlarmEventId, nextAlarmTime, currentMillis, alarmManager);
282         }
283     }
284 
285     /**
286      * Schedules an alarm for the EVENT_REMINDER_APP broadcast, for the specified
287      * alarm time with a slight delay (to account for the possible duplicate broadcast
288      * from the provider).
289      */
scheduleAlarm(Context context, long eventId, long alarmTime, long currentMillis, AlarmManagerInterface alarmManager)290     private static void scheduleAlarm(Context context, long eventId, long alarmTime,
291             long currentMillis, AlarmManagerInterface alarmManager) {
292         // Max out the alarm time to 1 day out, so an alert for an event far in the future
293         // (not present in our event query results for a limited range) can only be at
294         // most 1 day late.
295         long maxAlarmTime = currentMillis + MAX_ALARM_ELAPSED_MS;
296         if (alarmTime > maxAlarmTime) {
297             alarmTime = maxAlarmTime;
298         }
299 
300         // Add a slight delay (see comments on the member var).
301         alarmTime += ALARM_DELAY_MS;
302 
303         if (AlertService.DEBUG) {
304             Time time = new Time();
305             time.set(alarmTime);
306             String schedTime = time.format("%a, %b %d, %Y %I:%M%P");
307             Log.d(TAG, "Scheduling alarm for EVENT_REMINDER_APP broadcast for event " + eventId
308                     + " at " + alarmTime + " (" + schedTime + ")");
309         }
310 
311         // Schedule an EVENT_REMINDER_APP broadcast with AlarmManager.  The extra is
312         // only used by AlertService for logging.  It is ignored by Intent.filterEquals,
313         // so this scheduling will still overwrite the alarm that was previously pending.
314         // Note that the 'setClass' is required, because otherwise it seems the broadcast
315         // can be eaten by other apps and we somehow may never receive it.
316         Intent intent = new Intent(AlertReceiver.EVENT_REMINDER_APP_ACTION);
317         intent.setClass(context, AlertReceiver.class);
318         intent.putExtra(CalendarContract.CalendarAlerts.ALARM_TIME, alarmTime);
319         PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, 0);
320         alarmManager.set(AlarmManager.RTC_WAKEUP, alarmTime, pi);
321     }
322 }
323