/* * Copyright (C) 2010 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.providers.calendar; import com.android.providers.calendar.CalendarDatabaseHelper.Tables; import com.android.providers.calendar.CalendarDatabaseHelper.Views; import com.google.common.annotations.VisibleForTesting; import android.app.AlarmManager; import android.app.PendingIntent; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; import android.os.Build; import android.os.PowerManager; import android.os.PowerManager.WakeLock; import android.os.SystemClock; import android.provider.CalendarContract; import android.provider.CalendarContract.CalendarAlerts; import android.provider.CalendarContract.Calendars; 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 java.util.concurrent.atomic.AtomicBoolean; /** * We are using the CalendarAlertManager to be able to mock the AlarmManager as the AlarmManager * cannot be extended. * * CalendarAlertManager is delegating its calls to the real AlarmService. */ public class CalendarAlarmManager { protected static final String TAG = "CalendarAlarmManager"; // SCHEDULE_ALARM_URI runs scheduleNextAlarm(false) // SCHEDULE_ALARM_REMOVE_URI runs scheduleNextAlarm(true) // TODO: use a service to schedule alarms rather than private URI /* package */static final String SCHEDULE_ALARM_PATH = "schedule_alarms"; /* package */static final String SCHEDULE_ALARM_REMOVE_PATH = "schedule_alarms_remove"; /* package */static final String KEY_REMOVE_ALARMS = "removeAlarms"; /* package */static final Uri SCHEDULE_ALARM_REMOVE_URI = Uri.withAppendedPath( CalendarContract.CONTENT_URI, SCHEDULE_ALARM_REMOVE_PATH); /* package */static final Uri SCHEDULE_ALARM_URI = Uri.withAppendedPath( CalendarContract.CONTENT_URI, SCHEDULE_ALARM_PATH); /** * If no alarms are scheduled in the next 24h, check for future alarms again after this period * has passed. Scheduling the check 15 minutes earlier than 24h to prevent the scheduler alarm * from using up the alarms quota for reminders during dozing. * * @see AlarmManager#setExactAndAllowWhileIdle */ private static final long ALARM_CHECK_WHEN_NO_ALARM_IS_SCHEDULED_INTERVAL_MILLIS = DateUtils.DAY_IN_MILLIS - (15 * DateUtils.MINUTE_IN_MILLIS); static final String INVALID_CALENDARALERTS_SELECTOR = "_id IN (SELECT ca." + CalendarAlerts._ID + " FROM " + Tables.CALENDAR_ALERTS + " AS ca" + " LEFT OUTER JOIN " + Tables.INSTANCES + " USING (" + Instances.EVENT_ID + "," + Instances.BEGIN + "," + Instances.END + ")" + " LEFT OUTER JOIN " + Tables.REMINDERS + " AS r ON" + " (ca." + CalendarAlerts.EVENT_ID + "=r." + Reminders.EVENT_ID + " AND ca." + CalendarAlerts.MINUTES + "=r." + Reminders.MINUTES + ")" + " LEFT OUTER JOIN " + Views.EVENTS + " AS e ON" + " (ca." + CalendarAlerts.EVENT_ID + "=e." + Events._ID + ")" + " WHERE " + Tables.INSTANCES + "." + Instances.BEGIN + " ISNULL" + " OR ca." + CalendarAlerts.ALARM_TIME + "<?" + " OR (r." + Reminders.MINUTES + " ISNULL" + " AND ca." + CalendarAlerts.MINUTES + "<>0)" + " OR e." + Calendars.VISIBLE + "=0)"; /** * We search backward in time for event reminders that we may have missed * and schedule them if the event has not yet expired. The amount in the * past to search backwards is controlled by this constant. It should be at * least a few minutes to allow for an event that was recently created on * the web to make its way to the phone. Two hours might seem like overkill, * but it is useful in the case where the user just crossed into a new * timezone and might have just missed an alarm. */ private static final long SCHEDULE_ALARM_SLACK = 2 * DateUtils.HOUR_IN_MILLIS; /** * Alarms older than this threshold will be deleted from the CalendarAlerts * table. This should be at least a day because if the timezone is wrong and * the user corrects it we might delete good alarms that appear to be old * because the device time was incorrectly in the future. This threshold * must also be larger than SCHEDULE_ALARM_SLACK. We add the * SCHEDULE_ALARM_SLACK to ensure this. To make it easier to find and debug * problems with missed reminders, set this to something greater than a day. */ private static final long CLEAR_OLD_ALARM_THRESHOLD = 7 * DateUtils.DAY_IN_MILLIS + SCHEDULE_ALARM_SLACK; private static final String SCHEDULE_NEXT_ALARM_WAKE_LOCK = "ScheduleNextAlarmWakeLock"; protected static final String ACTION_CHECK_NEXT_ALARM = "com.android.providers.calendar.intent.CalendarProvider2"; static final int ALARM_CHECK_DELAY_MILLIS = 5000; /** * Used for tracking if the next alarm is already scheduled */ @VisibleForTesting protected AtomicBoolean mNextAlarmCheckScheduled; /** * Used for synchronization */ @VisibleForTesting protected Object mAlarmLock; /** * Used to keep the process from getting killed while scheduling alarms */ private final WakeLock mScheduleNextAlarmWakeLock; @VisibleForTesting protected Context mContext; private AlarmManager mAlarmManager; public CalendarAlarmManager(Context context) { initializeWithContext(context); PowerManager powerManager = (PowerManager) mContext.getSystemService( Context.POWER_SERVICE); // Create a wake lock that will be used when we are actually // scheduling the next alarm mScheduleNextAlarmWakeLock = powerManager.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, SCHEDULE_NEXT_ALARM_WAKE_LOCK); // We want the Wake Lock to be reference counted (so that we dont // need to take care // about its reference counting) mScheduleNextAlarmWakeLock.setReferenceCounted(true); } protected void initializeWithContext(Context context) { mContext = context; mAlarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); mNextAlarmCheckScheduled = new AtomicBoolean(false); mAlarmLock = new Object(); } private Intent getCheckNextAlarmIntent(boolean removeAlarms) { Intent intent = new Intent(CalendarAlarmManager.ACTION_CHECK_NEXT_ALARM); intent.setClass(mContext, CalendarProviderBroadcastReceiver.class); intent.putExtra(KEY_REMOVE_ALARMS, removeAlarms); return intent; } /** * Called by CalendarProvider to check the next alarm. A small delay is added before the real * checking happens in order to batch the requests. * * @param removeAlarms Remove scheduled alarms or not. See @{link * #removeScheduledAlarmsLocked} for details. */ void checkNextAlarm(boolean removeAlarms) { // We must always run the following when 'removeAlarms' is true. Previously it // was possible to have a race condition on startup between TIME_CHANGED and // BOOT_COMPLETED broadcast actions. This resulted in alarms being // missed (Bug 7221716) when the TIME_CHANGED broadcast ('removeAlarms' = false) // happened right before the BOOT_COMPLETED ('removeAlarms' = true), and the // BOOT_COMPLETED action was skipped since there was concurrent scheduling in progress. if (!mNextAlarmCheckScheduled.getAndSet(true) || removeAlarms) { if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { Log.d(CalendarProvider2.TAG, "Scheduling check of next Alarm"); } Intent intent = getCheckNextAlarmIntent(removeAlarms); PendingIntent pending = PendingIntent.getBroadcast(mContext, 0 /* ignored */, intent, PendingIntent.FLAG_NO_CREATE); if (pending != null) { // Cancel any previous Alarm check requests cancel(pending); } pending = PendingIntent.getBroadcast(mContext, 0 /* ignored */, intent, PendingIntent.FLAG_CANCEL_CURRENT); // Trigger the check in 5s from now, so that we can have batch processing. long triggerAtTime = SystemClock.elapsedRealtime() + ALARM_CHECK_DELAY_MILLIS; // Given to the short delay, we just use setExact here. setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, triggerAtTime, pending); } } /** * Similar to {@link #checkNextAlarm}, but schedule the checking at specific {@code * triggerTime}. In general, we do not need an alarm for scheduling. Instead we set the next * alarm check immediately when a reminder is shown. The only use case for this * is to schedule the next alarm check when there is no reminder within 1 day. * * @param triggerTimeMillis Time to run the next alarm check, in milliseconds. */ void scheduleNextAlarmCheck(long triggerTimeMillis) { Intent intent = getCheckNextAlarmIntent(false /* removeAlarms*/); PendingIntent pending = PendingIntent.getBroadcast( mContext, 0, intent, PendingIntent.FLAG_NO_CREATE); if (pending != null) { // Cancel any previous alarms that do the same thing. cancel(pending); } pending = PendingIntent.getBroadcast( mContext, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT); if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { Time time = new Time(); time.set(triggerTimeMillis); String timeStr = time.format(" %a, %b %d, %Y %I:%M%P"); Log.d(CalendarProvider2.TAG, "scheduleNextAlarmCheck at: " + triggerTimeMillis + timeStr); } setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTimeMillis, pending); } PowerManager.WakeLock getScheduleNextAlarmWakeLock() { return mScheduleNextAlarmWakeLock; } void acquireScheduleNextAlarmWakeLock() { getScheduleNextAlarmWakeLock().acquire(); } void releaseScheduleNextAlarmWakeLock() { try { getScheduleNextAlarmWakeLock().release(); } catch (RuntimeException e) { if (!e.getMessage().startsWith("WakeLock under-locked ")) { throw e; } Log.w(TAG, "WakeLock under-locked ignored."); } } void rescheduleMissedAlarms() { rescheduleMissedAlarms(mContext.getContentResolver()); } /** * This method runs in a background thread and schedules an alarm for the * next calendar event, if necessary. * * @param removeAlarms * @param cp2 */ void runScheduleNextAlarm(boolean removeAlarms, CalendarProvider2 cp2) { SQLiteDatabase db = cp2.mDb; if (db == null) { return; } // Reset so that we can accept other schedules of next alarm mNextAlarmCheckScheduled.set(false); db.beginTransaction(); try { if (removeAlarms) { removeScheduledAlarmsLocked(db); } scheduleNextAlarmLocked(db, cp2); db.setTransactionSuccessful(); } finally { db.endTransaction(); } } /** * This method looks at the 24-hour window from now for any events that it * needs to schedule. This method runs within a database transaction. It * also runs in a background thread. The CalendarProvider2 keeps track of * which alarms it has already scheduled to avoid scheduling them more than * once and for debugging problems with alarms. It stores this knowledge in * a database table called CalendarAlerts which persists across reboots. But * the actual alarm list is in memory and disappears if the phone loses * power. To avoid missing an alarm, we clear the entries in the * CalendarAlerts table when we start up the CalendarProvider2. Scheduling * an alarm multiple times is not tragic -- we filter out the extra ones * when we receive them. But we still need to keep track of the scheduled * alarms. The main reason is that we need to prevent multiple notifications * for the same alarm (on the receive side) in case we accidentally schedule * the same alarm multiple times. We don't have visibility into the system's * alarm list so we can never know for sure if we have already scheduled an * alarm and it's better to err on scheduling an alarm twice rather than * missing an alarm. Another reason we keep track of scheduled alarms in a * database table is that it makes it easy to run an SQL query to find the * next reminder that we haven't scheduled. * * @param db the database * @param cp2 TODO */ private void scheduleNextAlarmLocked(SQLiteDatabase db, CalendarProvider2 cp2) { Time time = new Time(); final long currentMillis = System.currentTimeMillis(); final long start = currentMillis - SCHEDULE_ALARM_SLACK; final long end = start + (24 * 60 * 60 * 1000); if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { time.set(start); String startTimeStr = time.format(" %a, %b %d, %Y %I:%M%P"); Log.d(CalendarProvider2.TAG, "runScheduleNextAlarm() start search: " + startTimeStr); } // Delete rows in CalendarAlert where the corresponding Instance or // Reminder no longer exist. // Also clear old alarms but keep alarms around for a while to prevent // multiple alerts for the same reminder. The "clearUpToTime' // should be further in the past than the point in time where // we start searching for events (the "start" variable defined above). String selectArg[] = new String[] { Long.toString( currentMillis - CLEAR_OLD_ALARM_THRESHOLD) }; int rowsDeleted = db.delete( CalendarAlerts.TABLE_NAME, INVALID_CALENDARALERTS_SELECTOR, selectArg); long nextAlarmTime = end; final ContentResolver resolver = mContext.getContentResolver(); final long tmpAlarmTime = CalendarAlerts.findNextAlarmTime(resolver, currentMillis); if (tmpAlarmTime != -1 && tmpAlarmTime < nextAlarmTime) { nextAlarmTime = tmpAlarmTime; } // Extract events from the database sorted by alarm time. The // alarm times are computed from Instances.begin (whose units // are milliseconds) and Reminders.minutes (whose units are // minutes). // // Also, ignore events whose end time is already in the past. // Also, ignore events alarms that we have already scheduled. // // Note 1: we can add support for the case where Reminders.minutes // equals -1 to mean use Calendars.minutes by adding a UNION for // that case where the two halves restrict the WHERE clause on // Reminders.minutes != -1 and Reminders.minutes = 1, respectively. // // Note 2: we have to name "myAlarmTime" different from the // "alarmTime" column in CalendarAlerts because otherwise the // query won't find multiple alarms for the same event. // // The CAST is needed in the query because otherwise the expression // will be untyped and sqlite3's manifest typing will not convert the // string query parameter to an int in myAlarmtime>=?, so the comparison // will fail. This could be simplified if bug 2464440 is resolved. time.setToNow(); time.normalize(false); long localOffset = time.gmtoff * 1000; String allDayOffset = " -(" + localOffset + ") "; String subQueryPrefix = "SELECT " + Instances.BEGIN; String subQuerySuffix = " -(" + Reminders.MINUTES + "*" + +DateUtils.MINUTE_IN_MILLIS + ")" + " AS myAlarmTime" + "," + Tables.INSTANCES + "." + Instances.EVENT_ID + " AS eventId" + "," + Instances.BEGIN + "," + Instances.END + "," + Instances.TITLE + "," + Instances.ALL_DAY + "," + Reminders.METHOD + "," + Reminders.MINUTES + " FROM " + Tables.INSTANCES + " INNER JOIN " + Views.EVENTS + " ON (" + Views.EVENTS + "." + Events._ID + "=" + Tables.INSTANCES + "." + Instances.EVENT_ID + ")" + " INNER JOIN " + Tables.REMINDERS + " ON (" + Tables.INSTANCES + "." + Instances.EVENT_ID + "=" + Tables.REMINDERS + "." + Reminders.EVENT_ID + ")" + " WHERE " + Calendars.VISIBLE + "=1" + " AND myAlarmTime>=CAST(? AS INT)" + " AND myAlarmTime<=CAST(? AS INT)" + " AND " + Instances.END + ">=?" + " AND " + Reminders.METHOD + "=" + Reminders.METHOD_ALERT; // we query separately for all day events to convert to local time from // UTC // we need to /subtract/ the offset to get the correct resulting local // time String allDayQuery = subQueryPrefix + allDayOffset + subQuerySuffix + " AND " + Instances.ALL_DAY + "=1"; String nonAllDayQuery = subQueryPrefix + subQuerySuffix + " AND " + Instances.ALL_DAY + "=0"; // we use UNION ALL because we are guaranteed to have no dupes between // the two queries, and it is less expensive String query = "SELECT *" + " FROM (" + allDayQuery + " UNION ALL " + nonAllDayQuery + ")" // avoid rescheduling existing alarms + " WHERE 0=(SELECT count(*) FROM " + Tables.CALENDAR_ALERTS + " CA" + " WHERE CA." + CalendarAlerts.EVENT_ID + "=eventId" + " AND CA." + CalendarAlerts.BEGIN + "=" + Instances.BEGIN + " AND CA." + CalendarAlerts.ALARM_TIME + "=myAlarmTime)" + " ORDER BY myAlarmTime," + Instances.BEGIN + "," + Instances.TITLE; String queryParams[] = new String[] { String.valueOf(start), String.valueOf(nextAlarmTime), String.valueOf(currentMillis), String.valueOf(start), String.valueOf(nextAlarmTime), String.valueOf(currentMillis) }; String instancesTimezone = cp2.mCalendarCache.readTimezoneInstances(); final String timezoneType = cp2.mCalendarCache.readTimezoneType(); boolean isHomeTimezone = CalendarCache.TIMEZONE_TYPE_HOME.equals(timezoneType); // expand this range by a day on either end to account for all day // events cp2.acquireInstanceRangeLocked( start - DateUtils.DAY_IN_MILLIS, end + DateUtils.DAY_IN_MILLIS, false /* * don't * use * minimum * expansion * windows */, false /* do not force Instances deletion and expansion */, instancesTimezone, isHomeTimezone); Cursor cursor = null; try { cursor = db.rawQuery(query, queryParams); final int beginIndex = cursor.getColumnIndex(Instances.BEGIN); final int endIndex = cursor.getColumnIndex(Instances.END); final int eventIdIndex = cursor.getColumnIndex("eventId"); final int alarmTimeIndex = cursor.getColumnIndex("myAlarmTime"); final int minutesIndex = cursor.getColumnIndex(Reminders.MINUTES); if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { time.set(nextAlarmTime); String alarmTimeStr = time.format(" %a, %b %d, %Y %I:%M%P"); Log.d(CalendarProvider2.TAG, "cursor results: " + cursor.getCount() + " nextAlarmTime: " + alarmTimeStr); } while (cursor.moveToNext()) { // Schedule all alarms whose alarm time is as early as any // scheduled alarm. For example, if the earliest alarm is at // 1pm, then we will schedule all alarms that occur at 1pm // but no alarms that occur later than 1pm. // Actually, we allow alarms up to a minute later to also // be scheduled so that we don't have to check immediately // again after an event alarm goes off. final long alarmTime = cursor.getLong(alarmTimeIndex); final long eventId = cursor.getLong(eventIdIndex); final int minutes = cursor.getInt(minutesIndex); final long startTime = cursor.getLong(beginIndex); final long endTime = cursor.getLong(endIndex); if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { time.set(alarmTime); String schedTime = time.format(" %a, %b %d, %Y %I:%M%P"); time.set(startTime); String startTimeStr = time.format(" %a, %b %d, %Y %I:%M%P"); Log.d(CalendarProvider2.TAG, " looking at id: " + eventId + " " + startTime + startTimeStr + " alarm: " + alarmTime + schedTime); } if (alarmTime < nextAlarmTime) { nextAlarmTime = alarmTime; } else if (alarmTime > nextAlarmTime + DateUtils.MINUTE_IN_MILLIS) { // This event alarm (and all later ones) will be scheduled // later. if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { Log.d(CalendarProvider2.TAG, "This event alarm (and all later ones) will be scheduled later"); } break; } // Avoid an SQLiteContraintException by checking if this alarm // already exists in the table. if (CalendarAlerts.alarmExists(resolver, eventId, startTime, alarmTime)) { if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { int titleIndex = cursor.getColumnIndex(Events.TITLE); String title = cursor.getString(titleIndex); Log.d(CalendarProvider2.TAG, " alarm exists for id: " + eventId + " " + title); } continue; } // Insert this alarm into the CalendarAlerts table Uri uri = CalendarAlerts.insert( resolver, eventId, startTime, endTime, alarmTime, minutes); if (uri == null) { if (Log.isLoggable(CalendarProvider2.TAG, Log.ERROR)) { Log.e(CalendarProvider2.TAG, "runScheduleNextAlarm() insert into " + "CalendarAlerts table failed"); } continue; } scheduleAlarm(alarmTime); } } finally { if (cursor != null) { cursor.close(); } } // Refresh notification bar if (rowsDeleted > 0) { scheduleAlarm(currentMillis); } // No event alarm is scheduled, check again in 24 hours. If a new // event is inserted before the next alarm check, then this method // will be run again when the new event is inserted. if (nextAlarmTime == Long.MAX_VALUE) { scheduleNextAlarmCheck( currentMillis + ALARM_CHECK_WHEN_NO_ALARM_IS_SCHEDULED_INTERVAL_MILLIS); } } /** * Removes the entries in the CalendarAlerts table for alarms that we have * scheduled but that have not fired yet. We do this to ensure that we don't * miss an alarm. The CalendarAlerts table keeps track of the alarms that we * have scheduled but the actual alarm list is in memory and will be cleared * if the phone reboots. We don't need to remove entries that have already * fired, and in fact we should not remove them because we need to display * the notifications until the user dismisses them. We could remove entries * that have fired and been dismissed, but we leave them around for a while * because it makes it easier to debug problems. Entries that are old enough * will be cleaned up later when we schedule new alarms. */ private static void removeScheduledAlarmsLocked(SQLiteDatabase db) { if (Log.isLoggable(CalendarProvider2.TAG, Log.DEBUG)) { Log.d(CalendarProvider2.TAG, "removing scheduled alarms"); } db.delete(CalendarAlerts.TABLE_NAME, CalendarAlerts.STATE + "=" + CalendarAlerts.STATE_SCHEDULED, null /* whereArgs */); } public void setExact(int type, long triggerAtTime, PendingIntent operation) { mAlarmManager.setExact(type, triggerAtTime, operation); } public void setExactAndAllowWhileIdle(int type, long triggerAtTime, PendingIntent operation) { mAlarmManager.setExactAndAllowWhileIdle(type, triggerAtTime, operation); } public void cancel(PendingIntent operation) { mAlarmManager.cancel(operation); } public void scheduleAlarm(long alarmTime) { // Debug log for investigating dozing related bugs, remove it once we confirm it is stable. if (Build.IS_DEBUGGABLE) { Log.d(TAG, "schedule reminder alarm fired at " + alarmTime); } CalendarContract.CalendarAlerts.scheduleAlarm(mContext, mAlarmManager, alarmTime); } public void rescheduleMissedAlarms(ContentResolver cr) { CalendarContract.CalendarAlerts.rescheduleMissedAlarms(cr, mContext, mAlarmManager); } }