| /* |
| * Copyright (C) 2013 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License |
| */ |
| |
| package com.android.calendar.alerts; |
| |
| import android.content.BroadcastReceiver; |
| import android.content.ContentResolver; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.SharedPreferences; |
| import android.database.Cursor; |
| import android.net.Uri; |
| import android.os.AsyncTask; |
| import android.os.Bundle; |
| import android.provider.CalendarContract.CalendarAlerts; |
| import android.provider.CalendarContract.Calendars; |
| import android.provider.CalendarContract.Events; |
| import android.util.Log; |
| import android.util.Pair; |
| |
| import com.android.calendar.CloudNotificationBackplane; |
| import com.android.calendar.ExtensionsFactory; |
| import com.android.calendar.R; |
| |
| import java.io.IOException; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.Iterator; |
| import java.util.LinkedHashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| /** |
| * Utilities for managing notification dismissal across devices. |
| */ |
| public class GlobalDismissManager extends BroadcastReceiver { |
| private static class GlobalDismissId { |
| public final String mAccountName; |
| public final String mSyncId; |
| public final long mStartTime; |
| |
| private GlobalDismissId(String accountName, String syncId, long startTime) { |
| // TODO(psliwowski): Add guava library to use Preconditions class |
| if (accountName == null) { |
| throw new IllegalArgumentException("Account Name can not be set to null"); |
| } else if (syncId == null) { |
| throw new IllegalArgumentException("SyncId can not be set to null"); |
| } |
| mAccountName = accountName; |
| mSyncId = syncId; |
| mStartTime = startTime; |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if (this == o) { |
| return true; |
| } |
| if (o == null || getClass() != o.getClass()) { |
| return false; |
| } |
| |
| GlobalDismissId that = (GlobalDismissId) o; |
| |
| if (mStartTime != that.mStartTime) { |
| return false; |
| } |
| if (!mAccountName.equals(that.mAccountName)) { |
| return false; |
| } |
| if (!mSyncId.equals(that.mSyncId)) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| @Override |
| public int hashCode() { |
| int result = mAccountName.hashCode(); |
| result = 31 * result + mSyncId.hashCode(); |
| result = 31 * result + (int) (mStartTime ^ (mStartTime >>> 32)); |
| return result; |
| } |
| } |
| |
| public static class LocalDismissId { |
| public final String mAccountType; |
| public final String mAccountName; |
| public final long mEventId; |
| public final long mStartTime; |
| |
| public LocalDismissId(String accountType, String accountName, long eventId, |
| long startTime) { |
| if (accountType == null) { |
| throw new IllegalArgumentException("Account Type can not be null"); |
| } else if (accountName == null) { |
| throw new IllegalArgumentException("Account Name can not be null"); |
| } |
| |
| mAccountType = accountType; |
| mAccountName = accountName; |
| mEventId = eventId; |
| mStartTime = startTime; |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if (this == o) { |
| return true; |
| } |
| if (o == null || getClass() != o.getClass()) { |
| return false; |
| } |
| |
| LocalDismissId that = (LocalDismissId) o; |
| |
| if (mEventId != that.mEventId) { |
| return false; |
| } |
| if (mStartTime != that.mStartTime) { |
| return false; |
| } |
| if (!mAccountName.equals(that.mAccountName)) { |
| return false; |
| } |
| if (!mAccountType.equals(that.mAccountType)) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| @Override |
| public int hashCode() { |
| int result = mAccountType.hashCode(); |
| result = 31 * result + mAccountName.hashCode(); |
| result = 31 * result + (int) (mEventId ^ (mEventId >>> 32)); |
| result = 31 * result + (int) (mStartTime ^ (mStartTime >>> 32)); |
| return result; |
| } |
| } |
| |
| public static class AlarmId { |
| public long mEventId; |
| public long mStart; |
| |
| public AlarmId(long id, long start) { |
| mEventId = id; |
| mStart = start; |
| } |
| } |
| |
| private static final long TIME_TO_LIVE = 1 * 60 * 60 * 1000; // 1 hour |
| |
| private static final String TAG = "GlobalDismissManager"; |
| private static final String GOOGLE_ACCOUNT_TYPE = "com.google"; |
| private static final String GLOBAL_DISMISS_MANAGER_PREFS = "com.android.calendar.alerts.GDM"; |
| private static final String ACCOUNT_KEY = "known_accounts"; |
| |
| static final String[] EVENT_PROJECTION = new String[] { |
| Events._ID, |
| Events.CALENDAR_ID |
| }; |
| static final String[] EVENT_SYNC_PROJECTION = new String[] { |
| Events._ID, |
| Events._SYNC_ID |
| }; |
| static final String[] CALENDARS_PROJECTION = new String[] { |
| Calendars._ID, |
| Calendars.ACCOUNT_NAME, |
| Calendars.ACCOUNT_TYPE |
| }; |
| |
| public static final String KEY_PREFIX = "com.android.calendar.alerts."; |
| public static final String SYNC_ID = KEY_PREFIX + "sync_id"; |
| public static final String START_TIME = KEY_PREFIX + "start_time"; |
| public static final String ACCOUNT_NAME = KEY_PREFIX + "account_name"; |
| public static final String DISMISS_INTENT = KEY_PREFIX + "DISMISS"; |
| |
| // TODO(psliwowski): Look into persisting these like AlertUtils.ALERTS_SHARED_PREFS_NAME |
| private static HashMap<GlobalDismissId, Long> sReceiverDismissCache = |
| new HashMap<GlobalDismissId, Long>(); |
| private static HashMap<LocalDismissId, Long> sSenderDismissCache = |
| new HashMap<LocalDismissId, Long>(); |
| |
| /** |
| * Look for unknown accounts in a set of events and associate with them. |
| * Must not be called on main thread. |
| * |
| * @param context application context |
| * @param eventIds IDs for events that have posted notifications that may be |
| * dismissed. |
| */ |
| public static void processEventIds(Context context, Set<Long> eventIds) { |
| final String senderId = context.getResources().getString(R.string.notification_sender_id); |
| if (senderId == null || senderId.isEmpty()) { |
| Log.i(TAG, "no sender configured"); |
| return; |
| } |
| Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds); |
| Set<Long> calendars = new LinkedHashSet<Long>(); |
| calendars.addAll(eventsToCalendars.values()); |
| if (calendars.isEmpty()) { |
| Log.d(TAG, "found no calendars for events"); |
| return; |
| } |
| |
| Map<Long, Pair<String, String>> calendarsToAccounts = |
| lookupCalendarToAccountMap(context, calendars); |
| |
| if (calendarsToAccounts.isEmpty()) { |
| Log.d(TAG, "found no accounts for calendars"); |
| return; |
| } |
| |
| // filter out non-google accounts (necessary?) |
| Set<String> accounts = new LinkedHashSet<String>(); |
| for (Pair<String, String> accountPair : calendarsToAccounts.values()) { |
| if (GOOGLE_ACCOUNT_TYPE.equals(accountPair.first)) { |
| accounts.add(accountPair.second); |
| } |
| } |
| |
| // filter out accounts we already know about |
| SharedPreferences prefs = |
| context.getSharedPreferences(GLOBAL_DISMISS_MANAGER_PREFS, |
| Context.MODE_PRIVATE); |
| Set<String> existingAccounts = prefs.getStringSet(ACCOUNT_KEY, |
| new HashSet<String>()); |
| accounts.removeAll(existingAccounts); |
| |
| if (accounts.isEmpty()) { |
| // nothing to do, we've already registered all the accounts. |
| return; |
| } |
| |
| // subscribe to remaining accounts |
| CloudNotificationBackplane cnb = |
| ExtensionsFactory.getCloudNotificationBackplane(); |
| if (cnb.open(context)) { |
| for (String account : accounts) { |
| try { |
| if (cnb.subscribeToGroup(senderId, account, account)) { |
| existingAccounts.add(account); |
| } |
| } catch (IOException e) { |
| // Try again, next time the account triggers and alert. |
| } |
| } |
| cnb.close(); |
| prefs.edit() |
| .putStringSet(ACCOUNT_KEY, existingAccounts) |
| .commit(); |
| } |
| } |
| |
| /** |
| * Some events don't have a global sync_id when they are dismissed. We need to wait |
| * until the data provider is updated before we can send the global dismiss message. |
| */ |
| public static void syncSenderDismissCache(Context context) { |
| final String senderId = context.getResources().getString(R.string.notification_sender_id); |
| if ("".equals(senderId)) { |
| Log.i(TAG, "no sender configured"); |
| return; |
| } |
| CloudNotificationBackplane cnb = ExtensionsFactory.getCloudNotificationBackplane(); |
| if (!cnb.open(context)) { |
| Log.i(TAG, "Unable to open cloud notification backplane"); |
| |
| } |
| |
| long currentTime = System.currentTimeMillis(); |
| ContentResolver resolver = context.getContentResolver(); |
| synchronized (sSenderDismissCache) { |
| Iterator<Map.Entry<LocalDismissId, Long>> it = |
| sSenderDismissCache.entrySet().iterator(); |
| while (it.hasNext()) { |
| Map.Entry<LocalDismissId, Long> entry = it.next(); |
| LocalDismissId dismissId = entry.getKey(); |
| |
| Uri uri = asSync(Events.CONTENT_URI, dismissId.mAccountType, |
| dismissId.mAccountName); |
| Cursor cursor = resolver.query(uri, EVENT_SYNC_PROJECTION, |
| Events._ID + " = " + dismissId.mEventId, null, null); |
| try { |
| cursor.moveToPosition(-1); |
| int sync_id_idx = cursor.getColumnIndex(Events._SYNC_ID); |
| if (sync_id_idx != -1) { |
| while (cursor.moveToNext()) { |
| String syncId = cursor.getString(sync_id_idx); |
| if (syncId != null) { |
| Bundle data = new Bundle(); |
| long startTime = dismissId.mStartTime; |
| String accountName = dismissId.mAccountName; |
| data.putString(SYNC_ID, syncId); |
| data.putString(START_TIME, Long.toString(startTime)); |
| data.putString(ACCOUNT_NAME, accountName); |
| try { |
| cnb.send(accountName, syncId + ":" + startTime, data); |
| it.remove(); |
| } catch (IOException e) { |
| // If we couldn't send, then leave dismissal in cache |
| } |
| } |
| } |
| } |
| } finally { |
| cursor.close(); |
| } |
| |
| // Remove old dismissals from cache after a certain time period |
| if (currentTime - entry.getValue() > TIME_TO_LIVE) { |
| it.remove(); |
| } |
| } |
| } |
| |
| cnb.close(); |
| } |
| |
| /** |
| * Globally dismiss notifications that are backed by the same events. |
| * |
| * @param context application context |
| * @param alarmIds Unique identifiers for events that have been dismissed by the user. |
| * @return true if notification_sender_id is available |
| */ |
| public static void dismissGlobally(Context context, List<AlarmId> alarmIds) { |
| Set<Long> eventIds = new HashSet<Long>(alarmIds.size()); |
| for (AlarmId alarmId: alarmIds) { |
| eventIds.add(alarmId.mEventId); |
| } |
| // find the mapping between calendars and events |
| Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds); |
| if (eventsToCalendars.isEmpty()) { |
| Log.d(TAG, "found no calendars for events"); |
| return; |
| } |
| |
| Set<Long> calendars = new LinkedHashSet<Long>(); |
| calendars.addAll(eventsToCalendars.values()); |
| |
| // find the accounts associated with those calendars |
| Map<Long, Pair<String, String>> calendarsToAccounts = |
| lookupCalendarToAccountMap(context, calendars); |
| if (calendarsToAccounts.isEmpty()) { |
| Log.d(TAG, "found no accounts for calendars"); |
| return; |
| } |
| |
| long currentTime = System.currentTimeMillis(); |
| for (AlarmId alarmId : alarmIds) { |
| Long calendar = eventsToCalendars.get(alarmId.mEventId); |
| Pair<String, String> account = calendarsToAccounts.get(calendar); |
| if (GOOGLE_ACCOUNT_TYPE.equals(account.first)) { |
| LocalDismissId dismissId = new LocalDismissId(account.first, account.second, |
| alarmId.mEventId, alarmId.mStart); |
| synchronized (sSenderDismissCache) { |
| sSenderDismissCache.put(dismissId, currentTime); |
| } |
| } |
| } |
| syncSenderDismissCache(context); |
| } |
| |
| private static Uri asSync(Uri uri, String accountType, String account) { |
| return uri |
| .buildUpon() |
| .appendQueryParameter( |
| android.provider.CalendarContract.CALLER_IS_SYNCADAPTER, "true") |
| .appendQueryParameter(Calendars.ACCOUNT_NAME, account) |
| .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build(); |
| } |
| |
| /** |
| * Build a selection over a set of row IDs |
| * |
| * @param ids row IDs to select |
| * @param key row name for the table |
| * @return a selection string suitable for a resolver query. |
| */ |
| private static String buildMultipleIdQuery(Set<Long> ids, String key) { |
| StringBuilder selection = new StringBuilder(); |
| boolean first = true; |
| for (Long id : ids) { |
| if (first) { |
| first = false; |
| } else { |
| selection.append(" OR "); |
| } |
| selection.append(key); |
| selection.append("="); |
| selection.append(id); |
| } |
| return selection.toString(); |
| } |
| |
| /** |
| * @param context application context |
| * @param eventIds Event row IDs to query. |
| * @return a map from event to calendar |
| */ |
| private static Map<Long, Long> lookupEventToCalendarMap(Context context, Set<Long> eventIds) { |
| Map<Long, Long> eventsToCalendars = new HashMap<Long, Long>(); |
| ContentResolver resolver = context.getContentResolver(); |
| String eventSelection = buildMultipleIdQuery(eventIds, Events._ID); |
| Cursor eventCursor = resolver.query(Events.CONTENT_URI, EVENT_PROJECTION, |
| eventSelection, null, null); |
| try { |
| eventCursor.moveToPosition(-1); |
| int calendar_id_idx = eventCursor.getColumnIndex(Events.CALENDAR_ID); |
| int event_id_idx = eventCursor.getColumnIndex(Events._ID); |
| if (calendar_id_idx != -1 && event_id_idx != -1) { |
| while (eventCursor.moveToNext()) { |
| eventsToCalendars.put(eventCursor.getLong(event_id_idx), |
| eventCursor.getLong(calendar_id_idx)); |
| } |
| } |
| } finally { |
| eventCursor.close(); |
| } |
| return eventsToCalendars; |
| } |
| |
| /** |
| * @param context application context |
| * @param calendars Calendar row IDs to query. |
| * @return a map from Calendar to a pair (account type, account name) |
| */ |
| private static Map<Long, Pair<String, String>> lookupCalendarToAccountMap(Context context, |
| Set<Long> calendars) { |
| Map<Long, Pair<String, String>> calendarsToAccounts = |
| new HashMap<Long, Pair<String, String>>(); |
| ContentResolver resolver = context.getContentResolver(); |
| String calendarSelection = buildMultipleIdQuery(calendars, Calendars._ID); |
| Cursor calendarCursor = resolver.query(Calendars.CONTENT_URI, CALENDARS_PROJECTION, |
| calendarSelection, null, null); |
| try { |
| calendarCursor.moveToPosition(-1); |
| int calendar_id_idx = calendarCursor.getColumnIndex(Calendars._ID); |
| int account_name_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_NAME); |
| int account_type_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_TYPE); |
| if (calendar_id_idx != -1 && account_name_idx != -1 && account_type_idx != -1) { |
| while (calendarCursor.moveToNext()) { |
| Long id = calendarCursor.getLong(calendar_id_idx); |
| String name = calendarCursor.getString(account_name_idx); |
| String type = calendarCursor.getString(account_type_idx); |
| if (name != null && type != null) { |
| calendarsToAccounts.put(id, new Pair<String, String>(type, name)); |
| } |
| } |
| } |
| } finally { |
| calendarCursor.close(); |
| } |
| return calendarsToAccounts; |
| } |
| |
| /** |
| * We can get global dismisses for events we don't know exists yet, so sync our cache |
| * with the data provider whenever it updates. |
| */ |
| public static void syncReceiverDismissCache(Context context) { |
| ContentResolver resolver = context.getContentResolver(); |
| long currentTime = System.currentTimeMillis(); |
| synchronized (sReceiverDismissCache) { |
| Iterator<Map.Entry<GlobalDismissId, Long>> it = |
| sReceiverDismissCache.entrySet().iterator(); |
| while (it.hasNext()) { |
| Map.Entry<GlobalDismissId, Long> entry = it.next(); |
| GlobalDismissId globalDismissId = entry.getKey(); |
| Uri uri = GlobalDismissManager.asSync(Events.CONTENT_URI, |
| GlobalDismissManager.GOOGLE_ACCOUNT_TYPE, globalDismissId.mAccountName); |
| Cursor cursor = resolver.query(uri, GlobalDismissManager.EVENT_SYNC_PROJECTION, |
| Events._SYNC_ID + " = '" + globalDismissId.mSyncId + "'", |
| null, null); |
| try { |
| int event_id_idx = cursor.getColumnIndex(Events._ID); |
| cursor.moveToFirst(); |
| if (event_id_idx != -1 && !cursor.isAfterLast()) { |
| long eventId = cursor.getLong(event_id_idx); |
| ContentValues values = new ContentValues(); |
| String selection = "(" + CalendarAlerts.STATE + "=" + |
| CalendarAlerts.STATE_FIRED + " OR " + |
| CalendarAlerts.STATE + "=" + |
| CalendarAlerts.STATE_SCHEDULED + ") AND " + |
| CalendarAlerts.EVENT_ID + "=" + eventId + " AND " + |
| CalendarAlerts.BEGIN + "=" + globalDismissId.mStartTime; |
| values.put(CalendarAlerts.STATE, CalendarAlerts.STATE_DISMISSED); |
| int rows = resolver.update(CalendarAlerts.CONTENT_URI, values, |
| selection, null); |
| if (rows > 0) { |
| it.remove(); |
| } |
| } |
| } finally { |
| cursor.close(); |
| } |
| |
| if (currentTime - entry.getValue() > TIME_TO_LIVE) { |
| it.remove(); |
| } |
| } |
| } |
| } |
| |
| @Override |
| @SuppressWarnings("unchecked") |
| public void onReceive(Context context, Intent intent) { |
| new AsyncTask<Pair<Context, Intent>, Void, Void>() { |
| @Override |
| protected Void doInBackground(Pair<Context, Intent>... params) { |
| Context context = params[0].first; |
| Intent intent = params[0].second; |
| if (intent.hasExtra(SYNC_ID) && intent.hasExtra(ACCOUNT_NAME) |
| && intent.hasExtra(START_TIME)) { |
| synchronized (sReceiverDismissCache) { |
| sReceiverDismissCache.put(new GlobalDismissId( |
| intent.getStringExtra(ACCOUNT_NAME), |
| intent.getStringExtra(SYNC_ID), |
| Long.parseLong(intent.getStringExtra(START_TIME)) |
| ), System.currentTimeMillis()); |
| } |
| AlertService.updateAlertNotification(context); |
| } |
| return null; |
| } |
| }.execute(new Pair<Context, Intent>(context, intent)); |
| } |
| } |