/* * Copyright (C) 2012 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.calendar.alerts; import android.app.AlarmManager; import android.app.PendingIntent; import android.content.ContentResolver; import android.content.ContentUris; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.provider.CalendarContract; import android.provider.CalendarContract.Events; import android.provider.CalendarContract.Instances; import android.provider.CalendarContract.Reminders; import android.text.format.DateUtils; import android.text.format.Time; import android.util.Log; import com.android.calendar.Utils; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Schedules the next EVENT_REMINDER_APP broadcast with AlarmManager, by querying the events * and reminders tables for the next upcoming alert. */ public class AlarmScheduler { private static final String TAG = "AlarmScheduler"; private static final String INSTANCES_WHERE = Events.VISIBLE + "=? AND " + Instances.BEGIN + ">=? AND " + Instances.BEGIN + "<=? AND " + Events.ALL_DAY + "=?"; static final String[] INSTANCES_PROJECTION = new String[] { Instances.EVENT_ID, Instances.BEGIN, Instances.ALL_DAY, }; private static final int INSTANCES_INDEX_EVENTID = 0; private static final int INSTANCES_INDEX_BEGIN = 1; private static final int INSTANCES_INDEX_ALL_DAY = 2; private static final String REMINDERS_WHERE = Reminders.METHOD + "=1 AND " + Reminders.EVENT_ID + " IN "; static final String[] REMINDERS_PROJECTION = new String[] { Reminders.EVENT_ID, Reminders.MINUTES, Reminders.METHOD, }; private static final int REMINDERS_INDEX_EVENT_ID = 0; private static final int REMINDERS_INDEX_MINUTES = 1; private static final int REMINDERS_INDEX_METHOD = 2; // Add a slight delay for the EVENT_REMINDER_APP broadcast for a couple reasons: // (1) so that the concurrent reminder broadcast from the provider doesn't result // in a double ring, and (2) some OEMs modified the provider to not add an alert to // the CalendarAlerts table until the alert time, so for the unbundled app's // notifications to work on these devices, a delay ensures that AlertService won't // read from the CalendarAlerts table until the alert is present. static final int ALARM_DELAY_MS = 1000; // The reminders query looks like "SELECT ... AND eventId IN 101,102,202,...". This // sets the max # of events in the query before batching into multiple queries, to // limit the SQL query length. private static final int REMINDER_QUERY_BATCH_SIZE = 50; // We really need to query for reminder times that fall in some interval, but // the Reminders table only stores the reminder interval (10min, 15min, etc), and // we cannot do the join with the Events table to calculate the actual alert time // from outside of the provider. So the best we can do for now consider events // whose start times begin within some interval (ie. 1 week out). This means // reminders which are configured for more than 1 week out won't fire on time. We // can minimize this to being only 1 day late by putting a 1 day max on the alarm time. private static final long EVENT_LOOKAHEAD_WINDOW_MS = DateUtils.WEEK_IN_MILLIS; private static final long MAX_ALARM_ELAPSED_MS = DateUtils.DAY_IN_MILLIS; /** * Schedules the nearest upcoming alarm, to refresh notifications. * * This is historically done in the provider but we dupe this here so the unbundled * app will work on devices that have modified this portion of the provider. This * has the limitation of querying events within some interval from now (ie. looks at * reminders for all events occurring in the next week). This means for example, * a 2 week notification will not fire on time. */ public static void scheduleNextAlarm(Context context) { scheduleNextAlarm(context, AlertUtils.createAlarmManager(context), REMINDER_QUERY_BATCH_SIZE, System.currentTimeMillis()); } // VisibleForTesting static void scheduleNextAlarm(Context context, AlarmManagerInterface alarmManager, int batchSize, long currentMillis) { Cursor instancesCursor = null; try { instancesCursor = queryUpcomingEvents(context, context.getContentResolver(), currentMillis); if (instancesCursor != null) { queryNextReminderAndSchedule(instancesCursor, context, context.getContentResolver(), alarmManager, batchSize, currentMillis); } } finally { if (instancesCursor != null) { instancesCursor.close(); } } } /** * Queries events starting within a fixed interval from now. */ private static Cursor queryUpcomingEvents(Context context, ContentResolver contentResolver, long currentMillis) { Time time = new Time(); time.normalize(false); long localOffset = time.gmtoff * 1000; final long localStartMin = currentMillis; final long localStartMax = localStartMin + EVENT_LOOKAHEAD_WINDOW_MS; final long utcStartMin = localStartMin - localOffset; final long utcStartMax = utcStartMin + EVENT_LOOKAHEAD_WINDOW_MS; // Expand Instances table range by a day on either end to account for // all-day events. Uri.Builder uriBuilder = Instances.CONTENT_URI.buildUpon(); ContentUris.appendId(uriBuilder, localStartMin - DateUtils.DAY_IN_MILLIS); ContentUris.appendId(uriBuilder, localStartMax + DateUtils.DAY_IN_MILLIS); // Build query for all events starting within the fixed interval. StringBuilder queryBuilder = new StringBuilder(); queryBuilder.append("("); queryBuilder.append(INSTANCES_WHERE); queryBuilder.append(") OR ("); queryBuilder.append(INSTANCES_WHERE); queryBuilder.append(")"); String[] queryArgs = new String[] { // allday selection "1", /* visible = ? */ String.valueOf(utcStartMin), /* begin >= ? */ String.valueOf(utcStartMax), /* begin <= ? */ "1", /* allDay = ? */ // non-allday selection "1", /* visible = ? */ String.valueOf(localStartMin), /* begin >= ? */ String.valueOf(localStartMax), /* begin <= ? */ "0" /* allDay = ? */ }; Cursor cursor = contentResolver.query(uriBuilder.build(), INSTANCES_PROJECTION, queryBuilder.toString(), queryArgs, null); return cursor; } /** * Queries for all the reminders of the events in the instancesCursor, and schedules * the alarm for the next upcoming reminder. */ private static void queryNextReminderAndSchedule(Cursor instancesCursor, Context context, ContentResolver contentResolver, AlarmManagerInterface alarmManager, int batchSize, long currentMillis) { if (AlertService.DEBUG) { int eventCount = instancesCursor.getCount(); if (eventCount == 0) { Log.d(TAG, "No events found starting within 1 week."); } else { Log.d(TAG, "Query result count for events starting within 1 week: " + eventCount); } } // Put query results of all events starting within some interval into map of event ID to // local start time. Map> eventMap = new HashMap>(); Time timeObj = new Time(); long nextAlarmTime = Long.MAX_VALUE; int nextAlarmEventId = 0; instancesCursor.moveToPosition(-1); while (!instancesCursor.isAfterLast()) { int index = 0; eventMap.clear(); StringBuilder eventIdsForQuery = new StringBuilder(); eventIdsForQuery.append('('); while (index++ < batchSize && instancesCursor.moveToNext()) { int eventId = instancesCursor.getInt(INSTANCES_INDEX_EVENTID); long begin = instancesCursor.getLong(INSTANCES_INDEX_BEGIN); boolean allday = instancesCursor.getInt(INSTANCES_INDEX_ALL_DAY) != 0; long localStartTime; if (allday) { // Adjust allday to local time. localStartTime = Utils.convertAlldayUtcToLocal(timeObj, begin, Time.getCurrentTimezone()); } else { localStartTime = begin; } List startTimes = eventMap.get(eventId); if (startTimes == null) { startTimes = new ArrayList(); eventMap.put(eventId, startTimes); eventIdsForQuery.append(eventId); eventIdsForQuery.append(","); } startTimes.add(localStartTime); // Log for debugging. if (Log.isLoggable(TAG, Log.DEBUG)) { timeObj.set(localStartTime); StringBuilder msg = new StringBuilder(); msg.append("Events cursor result -- eventId:").append(eventId); msg.append(", allDay:").append(allday); msg.append(", start:").append(localStartTime); msg.append(" (").append(timeObj.format("%a, %b %d, %Y %I:%M%P")).append(")"); Log.d(TAG, msg.toString()); } } if (eventIdsForQuery.charAt(eventIdsForQuery.length() - 1) == ',') { eventIdsForQuery.deleteCharAt(eventIdsForQuery.length() - 1); } eventIdsForQuery.append(')'); // Query the reminders table for the events found. Cursor cursor = null; try { cursor = contentResolver.query(Reminders.CONTENT_URI, REMINDERS_PROJECTION, REMINDERS_WHERE + eventIdsForQuery, null, null); // Process the reminders query results to find the next reminder time. cursor.moveToPosition(-1); while (cursor.moveToNext()) { int eventId = cursor.getInt(REMINDERS_INDEX_EVENT_ID); int reminderMinutes = cursor.getInt(REMINDERS_INDEX_MINUTES); List startTimes = eventMap.get(eventId); if (startTimes != null) { for (Long startTime : startTimes) { long alarmTime = startTime - reminderMinutes * DateUtils.MINUTE_IN_MILLIS; if (alarmTime > currentMillis && alarmTime < nextAlarmTime) { nextAlarmTime = alarmTime; nextAlarmEventId = eventId; } if (Log.isLoggable(TAG, Log.DEBUG)) { timeObj.set(alarmTime); StringBuilder msg = new StringBuilder(); msg.append("Reminders cursor result -- eventId:").append(eventId); msg.append(", startTime:").append(startTime); msg.append(", minutes:").append(reminderMinutes); msg.append(", alarmTime:").append(alarmTime); msg.append(" (").append(timeObj.format("%a, %b %d, %Y %I:%M%P")) .append(")"); Log.d(TAG, msg.toString()); } } } } } finally { if (cursor != null) { cursor.close(); } } } // Schedule the alarm for the next reminder time. if (nextAlarmTime < Long.MAX_VALUE) { scheduleAlarm(context, nextAlarmEventId, nextAlarmTime, currentMillis, alarmManager); } } /** * Schedules an alarm for the EVENT_REMINDER_APP broadcast, for the specified * alarm time with a slight delay (to account for the possible duplicate broadcast * from the provider). */ private static void scheduleAlarm(Context context, long eventId, long alarmTime, long currentMillis, AlarmManagerInterface alarmManager) { // Max out the alarm time to 1 day out, so an alert for an event far in the future // (not present in our event query results for a limited range) can only be at // most 1 day late. long maxAlarmTime = currentMillis + MAX_ALARM_ELAPSED_MS; if (alarmTime > maxAlarmTime) { alarmTime = maxAlarmTime; } // Add a slight delay (see comments on the member var). alarmTime += ALARM_DELAY_MS; if (AlertService.DEBUG) { Time time = new Time(); time.set(alarmTime); String schedTime = time.format("%a, %b %d, %Y %I:%M%P"); Log.d(TAG, "Scheduling alarm for EVENT_REMINDER_APP broadcast for event " + eventId + " at " + alarmTime + " (" + schedTime + ")"); } // Schedule an EVENT_REMINDER_APP broadcast with AlarmManager. The extra is // only used by AlertService for logging. It is ignored by Intent.filterEquals, // so this scheduling will still overwrite the alarm that was previously pending. // Note that the 'setClass' is required, because otherwise it seems the broadcast // can be eaten by other apps and we somehow may never receive it. Intent intent = new Intent(AlertReceiver.EVENT_REMINDER_APP_ACTION); intent.setClass(context, AlertReceiver.class); intent.putExtra(CalendarContract.CalendarAlerts.ALARM_TIME, alarmTime); PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, 0); alarmManager.set(AlarmManager.RTC_WAKEUP, alarmTime, pi); } }