Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 3a07a68d authored by Sara Ting's avatar Sara Ting
Browse files

Adding alert scheduling to app, to allow unbundled app's alerts to work on more devices.

Bug:7383861
Change-Id: I5dcffb8ac586966b21e938728be0393e6776f704
parent ddc1b123
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -222,7 +222,7 @@ public class GeneralPreferences extends PreferenceFragment implements
                if (mAlert.isChecked()) {
                    intent.setAction(AlertReceiver.ACTION_DISMISS_OLD_REMINDERS);
                } else {
                    intent.setAction(CalendarContract.ACTION_EVENT_REMINDER);
                    intent.setAction(AlertReceiver.EVENT_REMINDER_APP_ACTION);
                }
                a.sendBroadcast(intent);
            }
+1 −1
Original line number Diff line number Diff line
@@ -278,7 +278,7 @@ public class Utils {
        return mTardis;
    }

    static void setSharedPreference(Context context, String key, boolean value) {
    public static void setSharedPreference(Context context, String key, boolean value) {
        SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context);
        SharedPreferences.Editor editor = prefs.edit();
        editor.putBoolean(key, value);
+322 −0
Original line number Diff line number Diff line
/*
 * 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);
    }
}
+5 −0
Original line number Diff line number Diff line
@@ -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]",
+69 −1
Original line number Diff line number Diff line
@@ -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) {
Loading