Home | History | Annotate | Download | only in alerts
      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      */
    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