Home | History | Annotate | Download | only in alerts
      1 /*
      2  * Copyright (C) 2013 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.content.BroadcastReceiver;
     20 import android.content.ContentResolver;
     21 import android.content.ContentValues;
     22 import android.content.Context;
     23 import android.content.Intent;
     24 import android.content.SharedPreferences;
     25 import android.database.Cursor;
     26 import android.net.Uri;
     27 import android.os.AsyncTask;
     28 import android.os.Bundle;
     29 import android.provider.CalendarContract.CalendarAlerts;
     30 import android.provider.CalendarContract.Calendars;
     31 import android.provider.CalendarContract.Events;
     32 import android.util.Log;
     33 import android.util.Pair;
     34 
     35 import com.android.calendar.CloudNotificationBackplane;
     36 import com.android.calendar.ExtensionsFactory;
     37 import com.android.calendar.R;
     38 
     39 import java.io.IOException;
     40 import java.util.HashMap;
     41 import java.util.HashSet;
     42 import java.util.LinkedHashSet;
     43 import java.util.List;
     44 import java.util.Map;
     45 import java.util.Set;
     46 
     47 /**
     48  * Utilities for managing notification dismissal across devices.
     49  */
     50 public class GlobalDismissManager extends BroadcastReceiver {
     51     private static final String TAG = "GlobalDismissManager";
     52     private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
     53     private static final String GLOBAL_DISMISS_MANAGER_PREFS = "com.android.calendar.alerts.GDM";
     54     private static final String ACCOUNT_KEY = "known_accounts";
     55     protected static final long FOUR_WEEKS = 60 * 60 * 24 * 7 * 4;
     56 
     57     static final String[] EVENT_PROJECTION = new String[] {
     58             Events._ID,
     59             Events.CALENDAR_ID
     60     };
     61     static final String[] EVENT_SYNC_PROJECTION = new String[] {
     62             Events._ID,
     63             Events._SYNC_ID
     64     };
     65     static final String[] CALENDARS_PROJECTION = new String[] {
     66             Calendars._ID,
     67             Calendars.ACCOUNT_NAME,
     68             Calendars.ACCOUNT_TYPE
     69     };
     70 
     71     public static final String KEY_PREFIX = "com.android.calendar.alerts.";
     72     public static final String SYNC_ID = KEY_PREFIX + "sync_id";
     73     public static final String START_TIME = KEY_PREFIX + "start_time";
     74     public static final String ACCOUNT_NAME = KEY_PREFIX + "account_name";
     75     public static final String DISMISS_INTENT = KEY_PREFIX + "DISMISS";
     76 
     77     public static class AlarmId {
     78         public long mEventId;
     79         public long mStart;
     80          public AlarmId(long id, long start) {
     81              mEventId = id;
     82              mStart = start;
     83          }
     84     }
     85 
     86     /**
     87      * Look for unknown accounts in a set of events and associate with them.
     88      * Returns immediately, processing happens in the background.
     89      *
     90      * @param context application context
     91      * @param eventIds IDs for events that have posted notifications that may be
     92      *            dismissed.
     93      */
     94     public static void processEventIds(final Context context, final Set<Long> eventIds) {
     95         final String senderId = context.getResources().getString(R.string.notification_sender_id);
     96         if (senderId == null || senderId.isEmpty()) {
     97             Log.i(TAG, "no sender configured");
     98             return;
     99         }
    100         Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
    101         Set<Long> calendars = new LinkedHashSet<Long>();
    102         calendars.addAll(eventsToCalendars.values());
    103         if (calendars.isEmpty()) {
    104             Log.d(TAG, "found no calendars for events");
    105             return;
    106         }
    107 
    108         Map<Long, Pair<String, String>> calendarsToAccounts =
    109                 lookupCalendarToAccountMap(context, calendars);
    110 
    111         if (calendarsToAccounts.isEmpty()) {
    112             Log.d(TAG, "found no accounts for calendars");
    113             return;
    114         }
    115 
    116         // filter out non-google accounts (necessary?)
    117         Set<String> accounts = new LinkedHashSet<String>();
    118         for (Pair<String, String> accountPair : calendarsToAccounts.values()) {
    119             if (GOOGLE_ACCOUNT_TYPE.equals(accountPair.first)) {
    120                 accounts.add(accountPair.second);
    121             }
    122         }
    123 
    124         // filter out accounts we already know about
    125         SharedPreferences prefs =
    126                 context.getSharedPreferences(GLOBAL_DISMISS_MANAGER_PREFS,
    127                         Context.MODE_PRIVATE);
    128         Set<String> existingAccounts = prefs.getStringSet(ACCOUNT_KEY,
    129                 new HashSet<String>());
    130         accounts.removeAll(existingAccounts);
    131 
    132         if (accounts.isEmpty()) {
    133             // nothing to do, we've already registered all the accounts.
    134             return;
    135         }
    136 
    137         // subscribe to remaining accounts
    138         CloudNotificationBackplane cnb =
    139                 ExtensionsFactory.getCloudNotificationBackplane();
    140         if (cnb.open(context)) {
    141             for (String account : accounts) {
    142                 try {
    143                     if (cnb.subscribeToGroup(senderId, account, account)) {
    144                         existingAccounts.add(account);
    145                     }
    146                 } catch (IOException e) {
    147                     // Try again, next time the account triggers and alert.
    148                 }
    149             }
    150             cnb.close();
    151             prefs.edit()
    152             .putStringSet(ACCOUNT_KEY, existingAccounts)
    153             .commit();
    154         }
    155     }
    156 
    157     /**
    158      * Globally dismiss notifications that are backed by the same events.
    159      *
    160      * @param context application context
    161      * @param alarmIds Unique identifiers for events that have been dismissed by the user.
    162      * @return true if notification_sender_id is available
    163      */
    164     public static void dismissGlobally(final Context context, final List<AlarmId> alarmIds) {
    165         final String senderId = context.getResources().getString(R.string.notification_sender_id);
    166         if ("".equals(senderId)) {
    167             Log.i(TAG, "no sender configured");
    168             return;
    169         }
    170         Set<Long> eventIds = new HashSet<Long>(alarmIds.size());
    171         for (AlarmId alarmId: alarmIds) {
    172             eventIds.add(alarmId.mEventId);
    173         }
    174         // find the mapping between calendars and events
    175         Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
    176 
    177         if (eventsToCalendars.isEmpty()) {
    178             Log.d(TAG, "found no calendars for events");
    179             return;
    180         }
    181 
    182         Set<Long> calendars = new LinkedHashSet<Long>();
    183         calendars.addAll(eventsToCalendars.values());
    184 
    185         // find the accounts associated with those calendars
    186         Map<Long, Pair<String, String>> calendarsToAccounts =
    187                 lookupCalendarToAccountMap(context, calendars);
    188 
    189         if (calendarsToAccounts.isEmpty()) {
    190             Log.d(TAG, "found no accounts for calendars");
    191             return;
    192         }
    193 
    194         // TODO group by account to reduce queries
    195         Map<String, String> syncIdToAccount = new HashMap<String, String>();
    196         Map<Long, String> eventIdToSyncId = new HashMap<Long, String>();
    197         ContentResolver resolver = context.getContentResolver();
    198         for (Long eventId : eventsToCalendars.keySet()) {
    199             Long calendar = eventsToCalendars.get(eventId);
    200             Pair<String, String> account = calendarsToAccounts.get(calendar);
    201             if (GOOGLE_ACCOUNT_TYPE.equals(account.first)) {
    202                 Uri uri = asSync(Events.CONTENT_URI, account.first, account.second);
    203                 Cursor cursor = resolver.query(uri, EVENT_SYNC_PROJECTION,
    204                         Events._ID + " = " + eventId, null, null);
    205                 try {
    206                     cursor.moveToPosition(-1);
    207                     int sync_id_idx = cursor.getColumnIndex(Events._SYNC_ID);
    208                     if (sync_id_idx != -1) {
    209                         while (cursor.moveToNext()) {
    210                             String syncId = cursor.getString(sync_id_idx);
    211                             syncIdToAccount.put(syncId, account.second);
    212                             eventIdToSyncId.put(eventId, syncId);
    213                         }
    214                     }
    215                 } finally {
    216                     cursor.close();
    217                 }
    218             }
    219         }
    220 
    221         if (syncIdToAccount.isEmpty()) {
    222             Log.d(TAG, "found no syncIds for events");
    223             return;
    224         }
    225 
    226         // TODO group by account to reduce packets
    227         CloudNotificationBackplane cnb = ExtensionsFactory.getCloudNotificationBackplane();
    228         if (cnb.open(context)) {
    229             for (AlarmId alarmId: alarmIds) {
    230                 String syncId = eventIdToSyncId.get(alarmId.mEventId);
    231                 String account = syncIdToAccount.get(syncId);
    232                 Bundle data = new Bundle();
    233                 data.putString(SYNC_ID, syncId);
    234                 data.putString(START_TIME, Long.toString(alarmId.mStart));
    235                 data.putString(ACCOUNT_NAME, account);
    236                 try {
    237                     cnb.send(account, syncId + ":" + alarmId.mStart, data);
    238                 } catch (IOException e) {
    239                     // TODO save a note to try again later
    240                 }
    241             }
    242             cnb.close();
    243         }
    244     }
    245 
    246     private static Uri asSync(Uri uri, String accountType, String account) {
    247         return uri
    248                 .buildUpon()
    249                 .appendQueryParameter(
    250                         android.provider.CalendarContract.CALLER_IS_SYNCADAPTER, "true")
    251                 .appendQueryParameter(Calendars.ACCOUNT_NAME, account)
    252                 .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build();
    253     }
    254 
    255     /**
    256      * build a selection over a set of row IDs
    257      *
    258      * @param ids row IDs to select
    259      * @param key row name for the table
    260      * @return a selection string suitable for a resolver query.
    261      */
    262     private static String buildMultipleIdQuery(Set<Long> ids, String key) {
    263         StringBuilder selection = new StringBuilder();
    264         boolean first = true;
    265         for (Long id : ids) {
    266             if (first) {
    267                 first = false;
    268             } else {
    269                 selection.append(" OR ");
    270             }
    271             selection.append(key);
    272             selection.append("=");
    273             selection.append(id);
    274         }
    275         return selection.toString();
    276     }
    277 
    278     /**
    279      * @param context application context
    280      * @param eventIds Event row IDs to query.
    281      * @return a map from event to calendar
    282      */
    283     private static Map<Long, Long> lookupEventToCalendarMap(final Context context,
    284             final Set<Long> eventIds) {
    285         Map<Long, Long> eventsToCalendars = new HashMap<Long, Long>();
    286         ContentResolver resolver = context.getContentResolver();
    287         String eventSelection = buildMultipleIdQuery(eventIds, Events._ID);
    288         Cursor eventCursor = resolver.query(Events.CONTENT_URI, EVENT_PROJECTION,
    289                 eventSelection, null, null);
    290         try {
    291             eventCursor.moveToPosition(-1);
    292             int calendar_id_idx = eventCursor.getColumnIndex(Events.CALENDAR_ID);
    293             int event_id_idx = eventCursor.getColumnIndex(Events._ID);
    294             if (calendar_id_idx != -1 && event_id_idx != -1) {
    295                 while (eventCursor.moveToNext()) {
    296                     eventsToCalendars.put(eventCursor.getLong(event_id_idx),
    297                             eventCursor.getLong(calendar_id_idx));
    298                 }
    299             }
    300         } finally {
    301             eventCursor.close();
    302         }
    303         return eventsToCalendars;
    304     }
    305 
    306     /**
    307      * @param context application context
    308      * @param calendars Calendar row IDs to query.
    309      * @return a map from Calendar to a pair (account type, account name)
    310      */
    311     private static Map<Long, Pair<String, String>> lookupCalendarToAccountMap(final Context context,
    312             Set<Long> calendars) {
    313         Map<Long, Pair<String, String>> calendarsToAccounts =
    314                 new HashMap<Long, Pair<String, String>>();
    315         ;
    316         ContentResolver resolver = context.getContentResolver();
    317         String calendarSelection = buildMultipleIdQuery(calendars, Calendars._ID);
    318         Cursor calendarCursor = resolver.query(Calendars.CONTENT_URI, CALENDARS_PROJECTION,
    319                 calendarSelection, null, null);
    320         try {
    321             calendarCursor.moveToPosition(-1);
    322             int calendar_id_idx = calendarCursor.getColumnIndex(Calendars._ID);
    323             int account_name_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_NAME);
    324             int account_type_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_TYPE);
    325             if (calendar_id_idx != -1 && account_name_idx != -1 && account_type_idx != -1) {
    326                 while (calendarCursor.moveToNext()) {
    327                     Long id = calendarCursor.getLong(calendar_id_idx);
    328                     String name = calendarCursor.getString(account_name_idx);
    329                     String type = calendarCursor.getString(account_type_idx);
    330                     calendarsToAccounts.put(id, new Pair<String, String>(type, name));
    331                 }
    332             }
    333         } finally {
    334             calendarCursor.close();
    335         }
    336         return calendarsToAccounts;
    337     }
    338 
    339     @SuppressWarnings("unchecked")
    340     @Override
    341     public void onReceive(Context context, Intent intent) {
    342         new AsyncTask<Pair<Context, Intent>, Void, Void>() {
    343             @Override
    344             protected Void doInBackground(Pair<Context, Intent>... params) {
    345                 Context context = params[0].first;
    346                 Intent intent = params[0].second;
    347                 boolean updated = false;
    348                 if (intent.hasExtra(SYNC_ID) && intent.hasExtra(ACCOUNT_NAME)) {
    349                     String syncId = intent.getStringExtra(SYNC_ID);
    350                     long startTime = Long.parseLong(intent.getStringExtra(START_TIME));
    351                     ContentResolver resolver = context.getContentResolver();
    352 
    353                     Uri uri = asSync(Events.CONTENT_URI, GOOGLE_ACCOUNT_TYPE,
    354                             intent.getStringExtra(ACCOUNT_NAME));
    355                     Cursor cursor = resolver.query(uri, EVENT_SYNC_PROJECTION,
    356                             Events._SYNC_ID + " = '" + syncId + "'", null, null);
    357                     try {
    358                         int event_id_idx = cursor.getColumnIndex(Events._ID);
    359                         cursor.moveToFirst();
    360                         if (event_id_idx != -1 && !cursor.isAfterLast()) {
    361                             long eventId = cursor.getLong(event_id_idx);
    362                             ContentValues values = new ContentValues();
    363                             String selection = CalendarAlerts.STATE + "=" +
    364                                     CalendarAlerts.STATE_FIRED + " AND " +
    365                                     CalendarAlerts.EVENT_ID + "=" + eventId + " AND " +
    366                                     CalendarAlerts.BEGIN + "=" + startTime;
    367                             values.put(CalendarAlerts.STATE, CalendarAlerts.STATE_DISMISSED);
    368                             int rows = resolver.update(CalendarAlerts.CONTENT_URI, values,
    369                                     selection, null);
    370                             updated = rows > 0;
    371                         }
    372                     } finally {
    373                         cursor.close();
    374                     }
    375                 }
    376 
    377                 if (updated) {
    378                     Log.d(TAG, "updating alarm state");
    379                     AlertService.updateAlertNotification(context);
    380                 }
    381                 return null;
    382             }
    383         }.execute(new Pair<Context, Intent>(context, intent));
    384     }
    385 }
    386