Auto-update Events.hasAlarm

The "hasAlarm" column in the Events table is supposed to be read-only
for applications, updated automatically by the provider as reminders
are added and deleted.  This wasn't implemented (the Calendar app was
doing it manually).

Bug 5424486

Change-Id: Id4d167fe081b77fbd514b9a700359fd84d9e43e8
diff --git a/src/com/android/providers/calendar/CalendarDatabaseHelper.java b/src/com/android/providers/calendar/CalendarDatabaseHelper.java
index a1db32b..586515c 100644
--- a/src/com/android/providers/calendar/CalendarDatabaseHelper.java
+++ b/src/com/android/providers/calendar/CalendarDatabaseHelper.java
@@ -2951,6 +2951,14 @@
         return SCHEMA_HTTPS + url.substring(SCHEMA_HTTP.length());
     }
 
+    /**
+     * Duplicates an event and its associated tables (Attendees, Reminders, ExtendedProperties).
+     * <p>
+     * Does not create a duplicate if the Calendar's "canPartiallyUpdate" is 0 or the Event's
+     * "dirty" is 1 (so we don't create more than one duplicate).
+     *
+     * @param id The _id of the event to duplicate.
+     */
     protected void duplicateEvent(final long id) {
         final SQLiteDatabase db = getWritableDatabase();
         final long canPartiallyUpdate = DatabaseUtils.longForQuery(db, "SELECT "
@@ -2985,6 +2993,14 @@
         copyEventRelatedTables(db, newId, id);
     }
 
+    /**
+     * Makes a copy of the Attendees, Reminders, and ExtendedProperties rows associated with
+     * a specific event.
+     *
+     * @param db The database.
+     * @param newId The ID of the new event.
+     * @param id The ID of the old event.
+     */
     static void copyEventRelatedTables(SQLiteDatabase db, long newId, long id) {
         db.execSQL("INSERT INTO " + Tables.REMINDERS
                 + " ( "  + CalendarContract.Reminders.EVENT_ID + ", "
diff --git a/src/com/android/providers/calendar/CalendarProvider2.java b/src/com/android/providers/calendar/CalendarProvider2.java
index 13ac300..dfa0056 100644
--- a/src/com/android/providers/calendar/CalendarProvider2.java
+++ b/src/com/android/providers/calendar/CalendarProvider2.java
@@ -68,6 +68,7 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 import java.util.TimeZone;
@@ -108,9 +109,13 @@
     private static final int EVENTS_ORIGINAL_ID_INDEX = 3;
     private static final int EVENTS_ORIGINAL_SYNC_ID_INDEX = 4;
 
+    // many tables have _id and event_id; pick a representative version to use as our generic
+    private static final String GENERIC_ID = Attendees._ID;
+    private static final String GENERIC_EVENT_ID = Attendees.EVENT_ID;
+
     private static final String[] ID_PROJECTION = new String[] {
-            Attendees._ID,
-            Attendees.EVENT_ID, // Assume these are the same for each table
+            GENERIC_ID,
+            GENERIC_EVENT_ID,
     };
     private static final int ID_INDEX = 0;
     private static final int EVENT_ID_INDEX = 1;
@@ -166,10 +171,6 @@
             " SET " + Events.DIRTY + "=1" +
             " WHERE " + Events._ID + "=?";
 
-    // many tables have _id and event_id; pick a representative version to use as our generic
-    private static final String GENERIC_ID = Events._ID;
-    private static final String GENERIC_EVENT_ID = Attendees.EVENT_ID;
-
     protected static final String SQL_WHERE_ID = GENERIC_ID + "=?";
     private static final String SQL_WHERE_EVENT_ID = GENERIC_EVENT_ID + "=?";
     private static final String SQL_WHERE_ORIGINAL_ID = Events.ORIGINAL_ID + "=?";
@@ -1561,7 +1562,7 @@
             }
 
             EventRecurrence excepRecurrence = new EventRecurrence();
-            excepRecurrence.parse(origRrule);  // TODO: add/use a copy constructor to EventRecurrence
+            excepRecurrence.parse(origRrule); // TODO: add/use a copy constructor to EventRecurrence
             excepRecurrence.count -= recurrences.length;
             values.put(Events.RRULE, excepRecurrence.toString());
 
@@ -2042,6 +2043,7 @@
                                 "allDay is true but sec, min, hour were not 0.");
                     }
                 }
+                updatedValues.remove(Events.HAS_ALARM);     // should not be set by caller
                 // Insert the row
                 id = mDbHelper.eventsInsert(updatedValues);
                 if (id != -1) {
@@ -2130,23 +2132,28 @@
                 updateEventAttendeeStatus(mDb, values);
                 break;
             case REMINDERS:
-                if (!values.containsKey(Reminders.EVENT_ID)) {
+            {
+                Long eventIdObj = values.getAsLong(Reminders.EVENT_ID);
+                if (eventIdObj == null) {
                     throw new IllegalArgumentException("Reminders values must "
-                            + "contain an event_id");
+                            + "contain a numeric event_id");
                 }
                 if (!callerIsSyncAdapter) {
-                    final Long eventId = values.getAsLong(Reminders.EVENT_ID);
-                    mDbHelper.duplicateEvent(eventId);
-                    setEventDirty(eventId);
+                    mDbHelper.duplicateEvent(eventIdObj);
+                    setEventDirty(eventIdObj);
                 }
                 id = mDbHelper.remindersInsert(values);
 
+                // We know this event has at least one reminder, so make sure "hasAlarm" is 1.
+                setHasAlarm(eventIdObj, 1);
+
                 // Schedule another event alarm, if necessary
                 if (Log.isLoggable(TAG, Log.DEBUG)) {
                     Log.d(TAG, "insertInternal() changing reminder");
                 }
                 mCalendarAlarm.scheduleNextAlarm(false /* do not remove alarms */);
                 break;
+            }
             case CALENDAR_ALERTS:
                 if (!values.containsKey(CalendarAlerts.EVENT_ID)) {
                     throw new IllegalArgumentException("CalendarAlerts values must "
@@ -2587,6 +2594,23 @@
     }
 
     /**
+     * Set the "hasAlarm" column in the database.
+     *
+     * @param eventId The _id of the Event to update.
+     * @param val The value to set it to (0 or 1).
+     */
+    private void setHasAlarm(long eventId, int val) {
+        ContentValues values = new ContentValues();
+        values.put(Events.HAS_ALARM, val);
+        int count = mDb.update(Tables.EVENTS, values, SQL_WHERE_ID,
+                new String[] { String.valueOf(eventId) });
+        if (count != 1) {
+            Log.w(TAG, "setHasAlarm on event " + eventId + " updated " + count +
+                    " rows (expected 1)");
+        }
+    }
+
+    /**
      * Calculates the "last date" of the event.  For a regular event this is the start time
      * plus the duration.  For a recurring event this is the start date of the last event in
      * the recurrence, plus the duration.  The event recurs forever, this returns -1.  If
@@ -2824,7 +2848,8 @@
                 if (callerIsSyncAdapter) {
                     return mDb.delete(Tables.ATTENDEES, selection, selectionArgs);
                 } else {
-                    return deleteFromTable(Tables.ATTENDEES, uri, selection, selectionArgs);
+                    return deleteFromEventRelatedTable(Tables.ATTENDEES, uri, selection,
+                            selectionArgs);
                 }
             }
             case ATTENDEES_ID:
@@ -2834,35 +2859,25 @@
                     return mDb.delete(Tables.ATTENDEES, SQL_WHERE_ID,
                             new String[] {String.valueOf(id)});
                 } else {
-                    return deleteFromTable(Tables.ATTENDEES, uri, null /* selection */,
+                    return deleteFromEventRelatedTable(Tables.ATTENDEES, uri, null /* selection */,
                                            null /* selectionArgs */);
                 }
             }
             case REMINDERS:
             {
-                if (callerIsSyncAdapter) {
-                    return mDb.delete(Tables.REMINDERS, selection, selectionArgs);
-                } else {
-                    return deleteFromTable(Tables.REMINDERS, uri, selection, selectionArgs);
-                }
+                return deleteReminders(uri, false, selection, selectionArgs, callerIsSyncAdapter);
             }
             case REMINDERS_ID:
             {
-                if (callerIsSyncAdapter) {
-                    long id = ContentUris.parseId(uri);
-                    return mDb.delete(Tables.REMINDERS, SQL_WHERE_ID,
-                            new String[] {String.valueOf(id)});
-                } else {
-                    return deleteFromTable(Tables.REMINDERS, uri, null /* selection */,
-                                           null /* selectionArgs */);
-                }
+                return deleteReminders(uri, true, null /*selection*/, null /*selectionArgs*/,
+                        callerIsSyncAdapter);
             }
             case EXTENDED_PROPERTIES:
             {
                 if (callerIsSyncAdapter) {
                     return mDb.delete(Tables.EXTENDED_PROPERTIES, selection, selectionArgs);
                 } else {
-                    return deleteFromTable(Tables.EXTENDED_PROPERTIES, uri, selection,
+                    return deleteFromEventRelatedTable(Tables.EXTENDED_PROPERTIES, uri, selection,
                             selectionArgs);
                 }
             }
@@ -2873,8 +2888,8 @@
                     return mDb.delete(Tables.EXTENDED_PROPERTIES, SQL_WHERE_ID,
                             new String[] {String.valueOf(id)});
                 } else {
-                    return deleteFromTable(Tables.EXTENDED_PROPERTIES, uri, null /* selection */,
-                                           null /* selectionArgs */);
+                    return deleteFromEventRelatedTable(Tables.EXTENDED_PROPERTIES, uri,
+                            null /* selection */, null /* selectionArgs */);
                 }
             }
             case CALENDAR_ALERTS:
@@ -2882,7 +2897,8 @@
                 if (callerIsSyncAdapter) {
                     return mDb.delete(Tables.CALENDAR_ALERTS, selection, selectionArgs);
                 } else {
-                    return deleteFromTable(Tables.CALENDAR_ALERTS, uri, selection, selectionArgs);
+                    return deleteFromEventRelatedTable(Tables.CALENDAR_ALERTS, uri, selection,
+                            selectionArgs);
                 }
             }
             case CALENDAR_ALERTS_ID:
@@ -3007,27 +3023,49 @@
     }
 
     /**
-     * Delete rows from a table and mark corresponding events as dirty.
+     * Delete rows from an Event-related table (e.g. Attendees) and mark corresponding events
+     * as dirty.
+     *
      * @param table The table to delete from
      * @param uri The URI specifying the rows
      * @param selection for the query
      * @param selectionArgs for the query
      */
-    private int deleteFromTable(String table, Uri uri, String selection, String[] selectionArgs) {
-        // Note that the query will return data according to the access restrictions,
-        // so we don't need to worry about deleting data we don't have permission to read.
-        final Cursor c = query(uri, ID_PROJECTION, selection, selectionArgs, null);
-        final ContentValues values = new ContentValues();
-        values.put(Events.DIRTY, "1");
+    private int deleteFromEventRelatedTable(String table, Uri uri, String selection,
+            String[] selectionArgs) {
+        if (table.equals(Tables.EVENTS)) {
+            throw new IllegalArgumentException("Don't delete Events with this method "
+                    + "(use deleteEventInternal)");
+        }
+
+        ContentValues dirtyValues = new ContentValues();
+        dirtyValues.put(Events.DIRTY, "1");
+
+        /*
+         * Re-issue the delete URI as a query.  Note that, if this is a by-ID request, the ID
+         * will be in the URI, not selection/selectionArgs.
+         *
+         * Note that the query will return data according to the access restrictions,
+         * so we don't need to worry about deleting data we don't have permission to read.
+         */
+        Cursor c = query(uri, ID_PROJECTION, selection, selectionArgs, GENERIC_EVENT_ID);
         int count = 0;
         try {
-            while(c.moveToNext()) {
-                final long id = c.getLong(ID_INDEX);
-                final long event_id = c.getLong(EVENT_ID_INDEX);
-                mDbHelper.duplicateEvent(event_id);
+            long prevEventId = -1;
+            while (c.moveToNext()) {
+                long id = c.getLong(ID_INDEX);
+                long eventId = c.getLong(EVENT_ID_INDEX);
+                // Duplicate the event.  As a minor optimization, don't try to duplicate an
+                // event that we just duplicated on the previous iteration.
+                if (eventId != prevEventId) {
+                    mDbHelper.duplicateEvent(eventId);
+                    prevEventId = eventId;
+                }
                 mDb.delete(table, SQL_WHERE_ID, new String[]{String.valueOf(id)});
-                mDb.update(Tables.EVENTS, values, SQL_WHERE_ID,
-                        new String[] {String.valueOf(event_id)});
+                if (eventId != prevEventId) {
+                    mDb.update(Tables.EVENTS, dirtyValues, SQL_WHERE_ID,
+                            new String[] { String.valueOf(eventId)} );
+                }
                 count++;
             }
         } finally {
@@ -3037,6 +3075,103 @@
     }
 
     /**
+     * Deletes rows from the Reminders table and marks the corresponding events as dirty.
+     * Ensures the hasAlarm column in the Event is updated.
+     *
+     * @return The number of rows deleted.
+     */
+    private int deleteReminders(Uri uri, boolean byId, String selection, String[] selectionArgs,
+            boolean callerIsSyncAdapter) {
+        /*
+         * If this is a by-ID URI, make sure we have a good ID.  Also, confirm that the
+         * selection is null, since we will be ignoring it.
+         */
+        long rowId = -1;
+        if (byId) {
+            if (!TextUtils.isEmpty(selection)) {
+                throw new UnsupportedOperationException("Selection not allowed for " + uri);
+            }
+            rowId = ContentUris.parseId(uri);
+            if (rowId < 0) {
+                throw new IllegalArgumentException("ID expected but not found in " + uri);
+            }
+        }
+
+        /*
+         * Determine the set of events affected by this operation.  There can be multiple
+         * reminders with the same event_id, so to avoid beating up the database with "how many
+         * reminders are left" and "duplicate this event" requests, we want to generate a list
+         * of affected event IDs and work off that.
+         *
+         * TODO: use GROUP BY to reduce the number of rows returned in the cursor.  (The content
+         * provider query() doesn't take it as an argument.)
+         */
+        HashSet<Long> eventIdSet = new HashSet<Long>();
+        Cursor c = query(uri, new String[] { Attendees.EVENT_ID }, selection, selectionArgs, null);
+        try {
+            while (c.moveToNext()) {
+                eventIdSet.add(c.getLong(0));
+            }
+        } finally {
+            c.close();
+        }
+
+        /*
+         * If this isn't a sync adapter, duplicate each event (along with its associated tables),
+         * and mark each as "dirty".  This is for the benefit of partial-update sync.
+         */
+        if (!callerIsSyncAdapter) {
+            ContentValues dirtyValues = new ContentValues();
+            dirtyValues.put(Events.DIRTY, "1");
+
+            Iterator<Long> iter = eventIdSet.iterator();
+            while (iter.hasNext()) {
+                long eventId = iter.next();
+                mDbHelper.duplicateEvent(eventId);
+                mDb.update(Tables.EVENTS, dirtyValues, SQL_WHERE_ID,
+                        new String[] { String.valueOf(eventId) });
+            }
+        }
+
+        /*
+         * Issue the original deletion request.  If we were called with a by-ID URI, generate
+         * a selection.
+         */
+        if (byId) {
+            selection = SQL_WHERE_ID;
+            selectionArgs = new String[] { String.valueOf(rowId) };
+        }
+        int delCount = mDb.delete(Tables.REMINDERS, selection, selectionArgs);
+
+        /*
+         * For each event, set "hasAlarm" to zero if we've deleted the last of the reminders.
+         * (If the event still has reminders, hasAlarm should already be 1.)  Because we're
+         * executing in an exclusive transaction there's no risk of racing against other
+         * database updates.
+         */
+        ContentValues noAlarmValues = new ContentValues();
+        noAlarmValues.put(Events.HAS_ALARM, 0);
+        Iterator<Long> iter = eventIdSet.iterator();
+        while (iter.hasNext()) {
+            long eventId = iter.next();
+
+            // Count up the number of reminders still associated with this event.
+            Cursor reminders = mDb.query(Tables.REMINDERS, new String[] { GENERIC_ID },
+                    SQL_WHERE_EVENT_ID, new String[] { String.valueOf(eventId) },
+                    null, null, null);
+            int reminderCount = reminders.getCount();
+            reminders.close();
+
+            if (reminderCount == 0) {
+                mDb.update(Tables.EVENTS, noAlarmValues, SQL_WHERE_ID,
+                        new String[] { String.valueOf(eventId) });
+            }
+        }
+
+        return delCount;
+    }
+
+    /**
      * Update rows in a table and, if this is a non-sync-adapter update, mark the corresponding
      * events as dirty.
      * <p>
@@ -3232,6 +3367,12 @@
     private int handleUpdateEvents(Cursor cursor, ContentValues updateValues,
             boolean callerIsSyncAdapter) {
         /*
+         * This field is considered read-only.  It should not be modified by applications or
+         * by the sync adapter.
+         */
+        updateValues.remove(Events.HAS_ALARM);
+
+        /*
          * For a single event, we can just load the event, merge modValues in, perform any
          * fix-ups (putting changes into modValues), check validity, and then update().  We have
          * to be careful that our fix-ups don't confuse the sync adapter.