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
17package com.android.calendar.alerts;
18
19import android.app.AlarmManager;
20import android.app.PendingIntent;
21import android.content.ContentResolver;
22import android.content.ContentUris;
23import android.content.Context;
24import android.content.Intent;
25import android.database.Cursor;
26import android.net.Uri;
27import android.provider.CalendarContract;
28import android.provider.CalendarContract.Events;
29import android.provider.CalendarContract.Instances;
30import android.provider.CalendarContract.Reminders;
31import android.text.format.DateUtils;
32import android.text.format.Time;
33import android.util.Log;
34
35import com.android.calendar.Utils;
36
37import java.util.ArrayList;
38import java.util.HashMap;
39import java.util.List;
40import 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 */
46public 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     */
104    public static void scheduleNextAlarm(Context context) {
105        scheduleNextAlarm(context, AlertUtils.createAlarmManager(context),
106                REMINDER_QUERY_BATCH_SIZE, System.currentTimeMillis());
107    }
108
109    // VisibleForTesting
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     */
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     */
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     */
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