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 17package com.android.calendar.alerts; 18 19import android.content.BroadcastReceiver; 20import android.content.ContentResolver; 21import android.content.ContentValues; 22import android.content.Context; 23import android.content.Intent; 24import android.content.SharedPreferences; 25import android.database.Cursor; 26import android.net.Uri; 27import android.os.AsyncTask; 28import android.os.Bundle; 29import android.provider.CalendarContract.CalendarAlerts; 30import android.provider.CalendarContract.Calendars; 31import android.provider.CalendarContract.Events; 32import android.util.Log; 33import android.util.Pair; 34 35import com.android.calendar.CloudNotificationBackplane; 36import com.android.calendar.ExtensionsFactory; 37import com.android.calendar.R; 38 39import java.io.IOException; 40import java.util.HashMap; 41import java.util.HashSet; 42import java.util.LinkedHashSet; 43import java.util.List; 44import java.util.Map; 45import java.util.Set; 46 47/** 48 * Utilities for managing notification dismissal across devices. 49 */ 50public 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