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 
GlobalDismissId(String accountName, String syncId, long startTime)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
equals(Object o)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
hashCode()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 
LocalDismissId(String accountType, String accountName, long eventId, long startTime)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
equals(Object o)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
hashCode()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 
AlarmId(long id, long start)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      */
processEventIds(Context context, Set<Long> eventIds)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      */
syncSenderDismissCache(Context context)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      */
dismissGlobally(Context context, List<AlarmId> alarmIds)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 
asSync(Uri uri, String accountType, String account)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      */
buildMultipleIdQuery(Set<Long> ids, String key)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      */
lookupEventToCalendarMap(Context context, Set<Long> eventIds)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      */
lookupCalendarToAccountMap(Context context, Set<Long> calendars)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      */
syncReceiverDismissCache(Context context)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")
onReceive(Context context, Intent intent)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