diff options
author | Sara Ting <sarating@google.com> | 2012-10-31 13:19:38 -0700 |
---|---|---|
committer | Sara Ting <sarating@google.com> | 2012-11-16 14:37:34 -0800 |
commit | 3a07a68da6460c36a5dbec5b8828baa4355dbe04 (patch) | |
tree | 5bedb69a0e53650b847a97c1ab3876d88037b945 /src/com/android/calendar/alerts | |
parent | ddc1b123074b7243f7d39071c8c39788e44d9079 (diff) | |
download | android_packages_apps_Calendar-3a07a68da6460c36a5dbec5b8828baa4355dbe04.tar.gz android_packages_apps_Calendar-3a07a68da6460c36a5dbec5b8828baa4355dbe04.tar.bz2 android_packages_apps_Calendar-3a07a68da6460c36a5dbec5b8828baa4355dbe04.zip |
Adding alert scheduling to app, to allow unbundled app's alerts to work on more devices.
Bug:7383861
Change-Id: I5dcffb8ac586966b21e938728be0393e6776f704
Diffstat (limited to 'src/com/android/calendar/alerts')
-rw-r--r-- | src/com/android/calendar/alerts/AlarmScheduler.java | 322 | ||||
-rw-r--r-- | src/com/android/calendar/alerts/AlertReceiver.java | 5 | ||||
-rw-r--r-- | src/com/android/calendar/alerts/AlertService.java | 70 | ||||
-rw-r--r-- | src/com/android/calendar/alerts/AlertUtils.java | 4 |
4 files changed, 398 insertions, 3 deletions
diff --git a/src/com/android/calendar/alerts/AlarmScheduler.java b/src/com/android/calendar/alerts/AlarmScheduler.java new file mode 100644 index 00000000..97828229 --- /dev/null +++ b/src/com/android/calendar/alerts/AlarmScheduler.java @@ -0,0 +1,322 @@ +/* + * 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<Integer, List<Long>> eventMap = new HashMap<Integer, List<Long>>(); + 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<Long> startTimes = eventMap.get(eventId); + if (startTimes == null) { + startTimes = new ArrayList<Long>(); + 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<Long> 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); + } +} diff --git a/src/com/android/calendar/alerts/AlertReceiver.java b/src/com/android/calendar/alerts/AlertReceiver.java index a0a82d54..23d4c3d6 100644 --- a/src/com/android/calendar/alerts/AlertReceiver.java +++ b/src/com/android/calendar/alerts/AlertReceiver.java @@ -69,6 +69,11 @@ public class AlertReceiver extends BroadcastReceiver { private static final String MAIL_ACTION = "com.android.calendar.MAIL"; private static final String EXTRA_EVENT_ID = "eventid"; + // The broadcast for notification refreshes scheduled by the app. This is to + // distinguish the EVENT_REMINDER broadcast sent by the provider. + public static final String EVENT_REMINDER_APP_ACTION = + "com.android.calendar.EVENT_REMINDER_APP"; + static final Object mStartingServiceSync = new Object(); static PowerManager.WakeLock mStartingService; private static final Pattern mBlankLinePattern = Pattern.compile("^\\s*$[\n\r]", diff --git a/src/com/android/calendar/alerts/AlertService.java b/src/com/android/calendar/alerts/AlertService.java index 9811dabf..94eeabed 100644 --- a/src/com/android/calendar/alerts/AlertService.java +++ b/src/com/android/calendar/alerts/AlertService.java @@ -50,6 +50,7 @@ import com.android.calendar.Utils; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Random; import java.util.TimeZone; /** @@ -111,6 +112,19 @@ public class AlertService extends Service { // Hard limit to the number of notifications displayed. public static final int MAX_NOTIFICATIONS = 20; + // Shared prefs key for storing whether the EVENT_REMINDER event from the provider + // was ever received. Some OEMs modified this provider broadcast, so we had to + // do the alarm scheduling here in the app, for the unbundled app's reminders to work. + // If the EVENT_REMINDER event was ever received, we know we can skip our secondary + // alarm scheduling. + private static final String PROVIDER_REMINDER_PREF_KEY = + "preference_received_provider_reminder_broadcast"; + private static Boolean sReceivedProviderReminderBroadcast = null; + + // Temporary constants for the experiment to force some users to rely on AlarmScheduler + // in the app for reminders. + private static final String REMINDER_EXPERIMENT_PREF_KEY = "preference_reminder_exp"; + // Added wrapper for testing public static class NotificationWrapper { Notification mNotification; @@ -172,8 +186,38 @@ public class AlertService extends Service { + " Action = " + action); } - if (action.equals(Intent.ACTION_PROVIDER_CHANGED) || + // In experiment, drop any action from EVENT_REMINDER broadcast, and rely only + // on EVENT_REMINDER_APP broadcast. + boolean inReminderExperiment = inReminderSchedulingExperiment(); + + // Some OEMs had changed the provider's EVENT_REMINDER broadcast to their own event, + // which broke our unbundled app's reminders. So we added backup alarm scheduling to the + // app, but we know we can turn it off if we ever receive the EVENT_REMINDER broadcast. + boolean providerReminder = action.equals( + android.provider.CalendarContract.ACTION_EVENT_REMINDER); + if (providerReminder) { + if (sReceivedProviderReminderBroadcast == null) { + sReceivedProviderReminderBroadcast = Utils.getSharedPreference(this, + PROVIDER_REMINDER_PREF_KEY, false); + } + + if (!sReceivedProviderReminderBroadcast) { + sReceivedProviderReminderBroadcast = true; + Log.d(TAG, "Setting key " + PROVIDER_REMINDER_PREF_KEY + " to: true"); + Utils.setSharedPreference(this, PROVIDER_REMINDER_PREF_KEY, true); + } + + if (inReminderExperiment) { + Log.d(TAG, "In reminder scheduling experiment, dropping action from " + + "provider's EVENT_REMINDER broadcast."); + return; + } + } + + if (providerReminder || + action.equals(Intent.ACTION_PROVIDER_CHANGED) || action.equals(android.provider.CalendarContract.ACTION_EVENT_REMINDER) || + action.equals(AlertReceiver.EVENT_REMINDER_APP_ACTION) || action.equals(Intent.ACTION_LOCALE_CHANGED)) { updateAlertNotification(this); } else if (action.equals(Intent.ACTION_BOOT_COMPLETED) @@ -184,6 +228,30 @@ public class AlertService extends Service { } else { Log.w(TAG, "Invalid action: " + action); } + + // Schedule the alarm for the next upcoming reminder, if not done by the provider. + if (sReceivedProviderReminderBroadcast == null || !sReceivedProviderReminderBroadcast + || inReminderExperiment) { + Log.d(TAG, "Scheduling next alarm with AlarmScheduler. " + + "sEventReminderReceived: " + sReceivedProviderReminderBroadcast + + ", inReminderExperiment: " + inReminderExperiment); + AlarmScheduler.scheduleNextAlarm(this); + } + } + + /** + * Temporary way to force some users through the alarm scheduling done in the app. + */ + private boolean inReminderSchedulingExperiment() { + SharedPreferences prefs = GeneralPreferences.getSharedPreferences(this); + if (!prefs.contains(REMINDER_EXPERIMENT_PREF_KEY)) { + boolean inExperiment = new Random().nextBoolean(); + Utils.setSharedPreference(this, REMINDER_EXPERIMENT_PREF_KEY, inExperiment); + Log.d(TAG, "Setting key " + REMINDER_EXPERIMENT_PREF_KEY + " to: " + + inExperiment); + return inExperiment; + } + return prefs.getBoolean(REMINDER_EXPERIMENT_PREF_KEY, true); } static void dismissOldAlerts(Context context) { diff --git a/src/com/android/calendar/alerts/AlertUtils.java b/src/com/android/calendar/alerts/AlertUtils.java index f68f5224..b9fb63a6 100644 --- a/src/com/android/calendar/alerts/AlertUtils.java +++ b/src/com/android/calendar/alerts/AlertUtils.java @@ -98,7 +98,7 @@ public class AlertUtils { * listeners when a reminder should be fired. The provider will keep * scheduled reminders up to date but apps may use this to implement snooze * functionality without modifying the reminders table. Scheduled alarms - * will generate an intent using {@link #ACTION_EVENT_REMINDER}. + * will generate an intent using AlertReceiver.EVENT_REMINDER_APP_ACTION. * * @param context A context for referencing system resources * @param manager The AlarmManager to use or null @@ -121,7 +121,7 @@ public class AlertUtils { private static void scheduleAlarmHelper(Context context, AlarmManagerInterface manager, long alarmTime, boolean quietUpdate) { int alarmType = AlarmManager.RTC_WAKEUP; - Intent intent = new Intent(CalendarContract.ACTION_EVENT_REMINDER); + Intent intent = new Intent(AlertReceiver.EVENT_REMINDER_APP_ACTION); intent.setClass(context, AlertReceiver.class); if (quietUpdate) { alarmType = AlarmManager.RTC; |