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.Iterator;
     43 import java.util.LinkedHashSet;
     44 import java.util.List;
     45 import java.util.Map;
     46 import java.util.Set;
     47 
     48 /**
     49  * Utilities for managing notification dismissal across devices.
     50  */
     51 public class GlobalDismissManager extends BroadcastReceiver {
     52     private static class GlobalDismissId {
     53         public final String mAccountName;
     54         public final String mSyncId;
     55         public final long mStartTime;
     56 
     57         private GlobalDismissId(String accountName, String syncId, long startTime) {
     58             // TODO(psliwowski): Add guava library to use Preconditions class
     59             if (accountName == null) {
     60                 throw new IllegalArgumentException("Account Name can not be set to null");
     61             } else if (syncId == null) {
     62                 throw new IllegalArgumentException("SyncId can not be set to null");
     63             }
     64             mAccountName = accountName;
     65             mSyncId = syncId;
     66             mStartTime = startTime;
     67         }
     68 
     69         @Override
     70         public boolean equals(Object o) {
     71             if (this == o) {
     72                 return true;
     73             }
     74             if (o == null || getClass() != o.getClass()) {
     75                 return false;
     76             }
     77 
     78             GlobalDismissId that = (GlobalDismissId) o;
     79 
     80             if (mStartTime != that.mStartTime) {
     81                 return false;
     82             }
     83             if (!mAccountName.equals(that.mAccountName)) {
     84                 return false;
     85             }
     86             if (!mSyncId.equals(that.mSyncId)) {
     87                 return false;
     88             }
     89 
     90             return true;
     91         }
     92 
     93         @Override
     94         public int hashCode() {
     95             int result = mAccountName.hashCode();
     96             result = 31 * result + mSyncId.hashCode();
     97             result = 31 * result + (int) (mStartTime ^ (mStartTime >>> 32));
     98             return result;
     99         }
    100     }
    101 
    102     public static class LocalDismissId {
    103         public final String mAccountType;
    104         public final String mAccountName;
    105         public final long mEventId;
    106         public final long mStartTime;
    107 
    108         public LocalDismissId(String accountType, String accountName, long eventId,
    109                 long startTime) {
    110             if (accountType == null) {
    111                 throw new IllegalArgumentException("Account Type can not be null");
    112             } else if (accountName == null) {
    113                 throw new IllegalArgumentException("Account Name can not be null");
    114             }
    115 
    116             mAccountType = accountType;
    117             mAccountName = accountName;
    118             mEventId = eventId;
    119             mStartTime = startTime;
    120         }
    121 
    122         @Override
    123         public boolean equals(Object o) {
    124             if (this == o) {
    125                 return true;
    126             }
    127             if (o == null || getClass() != o.getClass()) {
    128                 return false;
    129             }
    130 
    131             LocalDismissId that = (LocalDismissId) o;
    132 
    133             if (mEventId != that.mEventId) {
    134                 return false;
    135             }
    136             if (mStartTime != that.mStartTime) {
    137                 return false;
    138             }
    139             if (!mAccountName.equals(that.mAccountName)) {
    140                 return false;
    141             }
    142             if (!mAccountType.equals(that.mAccountType)) {
    143                 return false;
    144             }
    145 
    146             return true;
    147         }
    148 
    149         @Override
    150         public int hashCode() {
    151             int result = mAccountType.hashCode();
    152             result = 31 * result + mAccountName.hashCode();
    153             result = 31 * result + (int) (mEventId ^ (mEventId >>> 32));
    154             result = 31 * result + (int) (mStartTime ^ (mStartTime >>> 32));
    155             return result;
    156         }
    157     }
    158 
    159     public static class AlarmId {
    160         public long mEventId;
    161         public long mStart;
    162 
    163         public AlarmId(long id, long start) {
    164             mEventId = id;
    165             mStart = start;
    166         }
    167     }
    168 
    169     private static final long TIME_TO_LIVE = 1 * 60 * 60 * 1000; // 1 hour
    170 
    171     private static final String TAG = "GlobalDismissManager";
    172     private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
    173     private static final String GLOBAL_DISMISS_MANAGER_PREFS = "com.android.calendar.alerts.GDM";
    174     private static final String ACCOUNT_KEY = "known_accounts";
    175 
    176     static final String[] EVENT_PROJECTION = new String[] {
    177             Events._ID,
    178             Events.CALENDAR_ID
    179     };
    180     static final String[] EVENT_SYNC_PROJECTION = new String[] {
    181             Events._ID,
    182             Events._SYNC_ID
    183     };
    184     static final String[] CALENDARS_PROJECTION = new String[] {
    185             Calendars._ID,
    186             Calendars.ACCOUNT_NAME,
    187             Calendars.ACCOUNT_TYPE
    188     };
    189 
    190     public static final String KEY_PREFIX = "com.android.calendar.alerts.";
    191     public static final String SYNC_ID = KEY_PREFIX + "sync_id";
    192     public static final String START_TIME = KEY_PREFIX + "start_time";
    193     public static final String ACCOUNT_NAME = KEY_PREFIX + "account_name";
    194     public static final String DISMISS_INTENT = KEY_PREFIX + "DISMISS";
    195 
    196     // TODO(psliwowski): Look into persisting these like AlertUtils.ALERTS_SHARED_PREFS_NAME
    197     private static HashMap<GlobalDismissId, Long> sReceiverDismissCache =
    198             new HashMap<GlobalDismissId, Long>();
    199     private static HashMap<LocalDismissId, Long> sSenderDismissCache =
    200             new HashMap<LocalDismissId, Long>();
    201 
    202     /**
    203      * Look for unknown accounts in a set of events and associate with them.
    204      * Must not be called on main thread.
    205      *
    206      * @param context application context
    207      * @param eventIds IDs for events that have posted notifications that may be
    208      *            dismissed.
    209      */
    210     public static void processEventIds(Context context, Set<Long> eventIds) {
    211         final String senderId = context.getResources().getString(R.string.notification_sender_id);
    212         if (senderId == null || senderId.isEmpty()) {
    213             Log.i(TAG, "no sender configured");
    214             return;
    215         }
    216         Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
    217         Set<Long> calendars = new LinkedHashSet<Long>();
    218         calendars.addAll(eventsToCalendars.values());
    219         if (calendars.isEmpty()) {
    220             Log.d(TAG, "found no calendars for events");
    221             return;
    222         }
    223 
    224         Map<Long, Pair<String, String>> calendarsToAccounts =
    225                 lookupCalendarToAccountMap(context, calendars);
    226 
    227         if (calendarsToAccounts.isEmpty()) {
    228             Log.d(TAG, "found no accounts for calendars");
    229             return;
    230         }
    231 
    232         // filter out non-google accounts (necessary?)
    233         Set<String> accounts = new LinkedHashSet<String>();
    234         for (Pair<String, String> accountPair : calendarsToAccounts.values()) {
    235             if (GOOGLE_ACCOUNT_TYPE.equals(accountPair.first)) {
    236                 accounts.add(accountPair.second);
    237             }
    238         }
    239 
    240         // filter out accounts we already know about
    241         SharedPreferences prefs =
    242                 context.getSharedPreferences(GLOBAL_DISMISS_MANAGER_PREFS,
    243                         Context.MODE_PRIVATE);
    244         Set<String> existingAccounts = prefs.getStringSet(ACCOUNT_KEY,
    245                 new HashSet<String>());
    246         accounts.removeAll(existingAccounts);
    247 
    248         if (accounts.isEmpty()) {
    249             // nothing to do, we've already registered all the accounts.
    250             return;
    251         }
    252 
    253         // subscribe to remaining accounts
    254         CloudNotificationBackplane cnb =
    255                 ExtensionsFactory.getCloudNotificationBackplane();
    256         if (cnb.open(context)) {
    257             for (String account : accounts) {
    258                 try {
    259                     if (cnb.subscribeToGroup(senderId, account, account)) {
    260                         existingAccounts.add(account);
    261                     }
    262                 } catch (IOException e) {
    263                     // Try again, next time the account triggers and alert.
    264                 }
    265             }
    266             cnb.close();
    267             prefs.edit()
    268             .putStringSet(ACCOUNT_KEY, existingAccounts)
    269             .commit();
    270         }
    271     }
    272 
    273     /**
    274      * Some events don't have a global sync_id when they are dismissed. We need to wait
    275      * until the data provider is updated before we can send the global dismiss message.
    276      */
    277     public static void syncSenderDismissCache(Context context) {
    278         final String senderId = context.getResources().getString(R.string.notification_sender_id);
    279         if ("".equals(senderId)) {
    280             Log.i(TAG, "no sender configured");
    281             return;
    282         }
    283         CloudNotificationBackplane cnb = ExtensionsFactory.getCloudNotificationBackplane();
    284         if (!cnb.open(context)) {
    285             Log.i(TAG, "Unable to open cloud notification backplane");
    286 
    287         }
    288 
    289         long currentTime = System.currentTimeMillis();
    290         ContentResolver resolver = context.getContentResolver();
    291         synchronized (sSenderDismissCache) {
    292             Iterator<Map.Entry<LocalDismissId, Long>> it =
    293                     sSenderDismissCache.entrySet().iterator();
    294             while (it.hasNext()) {
    295                 Map.Entry<LocalDismissId, Long> entry = it.next();
    296                 LocalDismissId dismissId = entry.getKey();
    297 
    298                 Uri uri = asSync(Events.CONTENT_URI, dismissId.mAccountType,
    299                         dismissId.mAccountName);
    300                 Cursor cursor = resolver.query(uri, EVENT_SYNC_PROJECTION,
    301                         Events._ID + " = " + dismissId.mEventId, null, null);
    302                 try {
    303                     cursor.moveToPosition(-1);
    304                     int sync_id_idx = cursor.getColumnIndex(Events._SYNC_ID);
    305                     if (sync_id_idx != -1) {
    306                         while (cursor.moveToNext()) {
    307                             String syncId = cursor.getString(sync_id_idx);
    308                             if (syncId != null) {
    309                                 Bundle data = new Bundle();
    310                                 long startTime = dismissId.mStartTime;
    311                                 String accountName = dismissId.mAccountName;
    312                                 data.putString(SYNC_ID, syncId);
    313                                 data.putString(START_TIME, Long.toString(startTime));
    314                                 data.putString(ACCOUNT_NAME, accountName);
    315                                 try {
    316                                     cnb.send(accountName, syncId + ":" + startTime, data);
    317                                     it.remove();
    318                                 } catch (IOException e) {
    319                                     // If we couldn't send, then leave dismissal in cache
    320                                 }
    321                             }
    322                         }
    323                     }
    324                 } finally {
    325                     cursor.close();
    326                 }
    327 
    328                 // Remove old dismissals from cache after a certain time period
    329                 if (currentTime - entry.getValue() > TIME_TO_LIVE) {
    330                     it.remove();
    331                 }
    332             }
    333         }
    334 
    335         cnb.close();
    336     }
    337 
    338     /**
    339      * Globally dismiss notifications that are backed by the same events.
    340      *
    341      * @param context application context
    342      * @param alarmIds Unique identifiers for events that have been dismissed by the user.
    343      * @return true if notification_sender_id is available
    344      */
    345     public static void dismissGlobally(Context context, List<AlarmId> alarmIds) {
    346         Set<Long> eventIds = new HashSet<Long>(alarmIds.size());
    347         for (AlarmId alarmId: alarmIds) {
    348             eventIds.add(alarmId.mEventId);
    349         }
    350         // find the mapping between calendars and events
    351         Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
    352         if (eventsToCalendars.isEmpty()) {
    353             Log.d(TAG, "found no calendars for events");
    354             return;
    355         }
    356 
    357         Set<Long> calendars = new LinkedHashSet<Long>();
    358         calendars.addAll(eventsToCalendars.values());
    359 
    360         // find the accounts associated with those calendars
    361         Map<Long, Pair<String, String>> calendarsToAccounts =
    362                 lookupCalendarToAccountMap(context, calendars);
    363         if (calendarsToAccounts.isEmpty()) {
    364             Log.d(TAG, "found no accounts for calendars");
    365             return;
    366         }
    367 
    368         long currentTime = System.currentTimeMillis();
    369         for (AlarmId alarmId : alarmIds) {
    370             Long calendar = eventsToCalendars.get(alarmId.mEventId);
    371             Pair<String, String> account = calendarsToAccounts.get(calendar);
    372             if (GOOGLE_ACCOUNT_TYPE.equals(account.first)) {
    373                 LocalDismissId dismissId = new LocalDismissId(account.first, account.second,
    374                         alarmId.mEventId, alarmId.mStart);
    375                 synchronized (sSenderDismissCache) {
    376                     sSenderDismissCache.put(dismissId, currentTime);
    377                 }
    378             }
    379         }
    380         syncSenderDismissCache(context);
    381     }
    382 
    383     private static Uri asSync(Uri uri, String accountType, String account) {
    384         return uri
    385                 .buildUpon()
    386                 .appendQueryParameter(
    387                         android.provider.CalendarContract.CALLER_IS_SYNCADAPTER, "true")
    388                 .appendQueryParameter(Calendars.ACCOUNT_NAME, account)
    389                 .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build();
    390     }
    391 
    392     /**
    393      * Build a selection over a set of row IDs
    394      *
    395      * @param ids row IDs to select
    396      * @param key row name for the table
    397      * @return a selection string suitable for a resolver query.
    398      */
    399     private static String buildMultipleIdQuery(Set<Long> ids, String key) {
    400         StringBuilder selection = new StringBuilder();
    401         boolean first = true;
    402         for (Long id : ids) {
    403             if (first) {
    404                 first = false;
    405             } else {
    406                 selection.append(" OR ");
    407             }
    408             selection.append(key);
    409             selection.append("=");
    410             selection.append(id);
    411         }
    412         return selection.toString();
    413     }
    414 
    415     /**
    416      * @param context application context
    417      * @param eventIds Event row IDs to query.
    418      * @return a map from event to calendar
    419      */
    420     private static Map<Long, Long> lookupEventToCalendarMap(Context context, Set<Long> eventIds) {
    421         Map<Long, Long> eventsToCalendars = new HashMap<Long, Long>();
    422         ContentResolver resolver = context.getContentResolver();
    423         String eventSelection = buildMultipleIdQuery(eventIds, Events._ID);
    424         Cursor eventCursor = resolver.query(Events.CONTENT_URI, EVENT_PROJECTION,
    425                 eventSelection, null, null);
    426         try {
    427             eventCursor.moveToPosition(-1);
    428             int calendar_id_idx = eventCursor.getColumnIndex(Events.CALENDAR_ID);
    429             int event_id_idx = eventCursor.getColumnIndex(Events._ID);
    430             if (calendar_id_idx != -1 && event_id_idx != -1) {
    431                 while (eventCursor.moveToNext()) {
    432                     eventsToCalendars.put(eventCursor.getLong(event_id_idx),
    433                             eventCursor.getLong(calendar_id_idx));
    434                 }
    435             }
    436         } finally {
    437             eventCursor.close();
    438         }
    439         return eventsToCalendars;
    440     }
    441 
    442     /**
    443      * @param context application context
    444      * @param calendars Calendar row IDs to query.
    445      * @return a map from Calendar to a pair (account type, account name)
    446      */
    447     private static Map<Long, Pair<String, String>> lookupCalendarToAccountMap(Context context,
    448             Set<Long> calendars) {
    449         Map<Long, Pair<String, String>> calendarsToAccounts =
    450                 new HashMap<Long, Pair<String, String>>();
    451         ContentResolver resolver = context.getContentResolver();
    452         String calendarSelection = buildMultipleIdQuery(calendars, Calendars._ID);
    453         Cursor calendarCursor = resolver.query(Calendars.CONTENT_URI, CALENDARS_PROJECTION,
    454                 calendarSelection, null, null);
    455         try {
    456             calendarCursor.moveToPosition(-1);
    457             int calendar_id_idx = calendarCursor.getColumnIndex(Calendars._ID);
    458             int account_name_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_NAME);
    459             int account_type_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_TYPE);
    460             if (calendar_id_idx != -1 && account_name_idx != -1 && account_type_idx != -1) {
    461                 while (calendarCursor.moveToNext()) {
    462                     Long id = calendarCursor.getLong(calendar_id_idx);
    463                     String name = calendarCursor.getString(account_name_idx);
    464                     String type = calendarCursor.getString(account_type_idx);
    465                     if (name != null && type != null) {
    466                         calendarsToAccounts.put(id, new Pair<String, String>(type, name));
    467                     }
    468                 }
    469             }
    470         } finally {
    471             calendarCursor.close();
    472         }
    473         return calendarsToAccounts;
    474     }
    475 
    476     /**
    477      * We can get global dismisses for events we don't know exists yet, so sync our cache
    478      * with the data provider whenever it updates.
    479      */
    480     public static void syncReceiverDismissCache(Context context) {
    481         ContentResolver resolver = context.getContentResolver();
    482         long currentTime = System.currentTimeMillis();
    483         synchronized (sReceiverDismissCache) {
    484             Iterator<Map.Entry<GlobalDismissId, Long>> it =
    485                     sReceiverDismissCache.entrySet().iterator();
    486             while (it.hasNext()) {
    487                 Map.Entry<GlobalDismissId, Long> entry = it.next();
    488                 GlobalDismissId globalDismissId = entry.getKey();
    489                 Uri uri = GlobalDismissManager.asSync(Events.CONTENT_URI,
    490                         GlobalDismissManager.GOOGLE_ACCOUNT_TYPE, globalDismissId.mAccountName);
    491                 Cursor cursor = resolver.query(uri, GlobalDismissManager.EVENT_SYNC_PROJECTION,
    492                         Events._SYNC_ID + " = '" + globalDismissId.mSyncId + "'",
    493                         null, null);
    494                 try {
    495                     int event_id_idx = cursor.getColumnIndex(Events._ID);
    496                     cursor.moveToFirst();
    497                     if (event_id_idx != -1 && !cursor.isAfterLast()) {
    498                         long eventId = cursor.getLong(event_id_idx);
    499                         ContentValues values = new ContentValues();
    500                         String selection = "(" + CalendarAlerts.STATE + "=" +
    501                                 CalendarAlerts.STATE_FIRED + " OR " +
    502                                 CalendarAlerts.STATE + "=" +
    503                                 CalendarAlerts.STATE_SCHEDULED + ") AND " +
    504                                 CalendarAlerts.EVENT_ID + "=" + eventId + " AND " +
    505                                 CalendarAlerts.BEGIN + "=" + globalDismissId.mStartTime;
    506                         values.put(CalendarAlerts.STATE, CalendarAlerts.STATE_DISMISSED);
    507                         int rows = resolver.update(CalendarAlerts.CONTENT_URI, values,
    508                                 selection, null);
    509                         if (rows > 0) {
    510                             it.remove();
    511                         }
    512                     }
    513                 } finally {
    514                     cursor.close();
    515                 }
    516 
    517                 if (currentTime - entry.getValue() > TIME_TO_LIVE) {
    518                     it.remove();
    519                 }
    520             }
    521         }
    522     }
    523 
    524     @Override
    525     @SuppressWarnings("unchecked")
    526     public void onReceive(Context context, Intent intent) {
    527         new AsyncTask<Pair<Context, Intent>, Void, Void>() {
    528             @Override
    529             protected Void doInBackground(Pair<Context, Intent>... params) {
    530                 Context context = params[0].first;
    531                 Intent intent = params[0].second;
    532                 if (intent.hasExtra(SYNC_ID) && intent.hasExtra(ACCOUNT_NAME)
    533                         && intent.hasExtra(START_TIME)) {
    534                     synchronized (sReceiverDismissCache) {
    535                         sReceiverDismissCache.put(new GlobalDismissId(
    536                                 intent.getStringExtra(ACCOUNT_NAME),
    537                                 intent.getStringExtra(SYNC_ID),
    538                                 Long.parseLong(intent.getStringExtra(START_TIME))
    539                         ), System.currentTimeMillis());
    540                     }
    541                     AlertService.updateAlertNotification(context);
    542                 }
    543                 return null;
    544             }
    545         }.execute(new Pair<Context, Intent>(context, intent));
    546     }
    547 }
    548