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