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