GlobalDismissManager.java revision 5f44e994a6f1dabf5a208f0035d476e71460626b
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.Bundle;
28import android.provider.CalendarContract.CalendarAlerts;
29import android.provider.CalendarContract.Calendars;
30import android.provider.CalendarContract.Events;
31import android.util.Log;
32import android.util.Pair;
33
34import com.android.calendar.CloudNotificationBackplane;
35import com.android.calendar.ExtensionsFactory;
36import com.android.calendar.R;
37
38import java.io.IOException;
39import java.util.HashMap;
40import java.util.HashSet;
41import java.util.LinkedHashSet;
42import java.util.List;
43import java.util.Map;
44import java.util.Set;
45
46/**
47 * Utilities for managing notification dismissal across devices.
48 */
49public class GlobalDismissManager extends BroadcastReceiver {
50    private static final String TAG = "GlobalDismissManager";
51    private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
52    private static final String GLOBAL_DISMISS_MANAGER_PREFS = "com.android.calendar.alerts.GDM";
53    private static final String ACCOUNT_KEY = "known_accounts";
54    protected static final long FOUR_WEEKS = 60 * 60 * 24 * 7 * 4;
55
56    static final String[] EVENT_PROJECTION = new String[] {
57            Events._ID,
58            Events.CALENDAR_ID
59    };
60    static final String[] EVENT_SYNC_PROJECTION = new String[] {
61            Events._ID,
62            Events._SYNC_ID
63    };
64    static final String[] CALENDARS_PROJECTION = new String[] {
65            Calendars._ID,
66            Calendars.ACCOUNT_NAME,
67            Calendars.ACCOUNT_TYPE
68    };
69
70    public static final String KEY_PREFIX = "com.android.calendar.alerts.";
71    public static final String SYNC_ID = KEY_PREFIX + "sync_id";
72    public static final String START_TIME = KEY_PREFIX + "start_time";
73    public static final String ACCOUNT_NAME = KEY_PREFIX + "account_name";
74    public static final String DISMISS_INTENT = KEY_PREFIX + "DISMISS";
75
76    public static class AlarmId {
77        public long mEventId;
78        public long mStart;
79         public AlarmId(long id, long start) {
80             mEventId = id;
81             mStart = start;
82         }
83    }
84
85    /**
86     * Look for unknown accounts in a set of events and associate with them.
87     * Returns immediately, processing happens in the background.
88     *
89     * @param context application context
90     * @param eventIds IDs for events that have posted notifications that may be
91     *            dismissed.
92     */
93    public static void processEventIds(final Context context, final Set<Long> eventIds) {
94        final String senderId = context.getResources().getString(R.string.notification_sender_id);
95        if (senderId == null || senderId.isEmpty()) {
96            Log.i(TAG, "no sender configured");
97            return;
98        }
99        Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
100        Set<Long> calendars = new LinkedHashSet<Long>();
101        calendars.addAll(eventsToCalendars.values());
102        if (calendars.isEmpty()) {
103            Log.d(TAG, "found no calendars for events");
104            return;
105        }
106
107        Map<Long, Pair<String, String>> calendarsToAccounts =
108                lookupCalendarToAccountMap(context, calendars);
109
110        if (calendarsToAccounts.isEmpty()) {
111            Log.d(TAG, "found no accounts for calendars");
112            return;
113        }
114
115        // filter out non-google accounts (necessary?)
116        Set<String> accounts = new LinkedHashSet<String>();
117        for (Pair<String, String> accountPair : calendarsToAccounts.values()) {
118            if (GOOGLE_ACCOUNT_TYPE.equals(accountPair.first)) {
119                accounts.add(accountPair.second);
120            }
121        }
122
123        // filter out accounts we already know about
124        SharedPreferences prefs =
125                context.getSharedPreferences(GLOBAL_DISMISS_MANAGER_PREFS,
126                        Context.MODE_PRIVATE);
127        Set<String> existingAccounts = prefs.getStringSet(ACCOUNT_KEY,
128                new HashSet<String>());
129        accounts.removeAll(existingAccounts);
130
131        if (accounts.isEmpty()) {
132            // nothing to do, we've already registered all the accounts.
133            return;
134        }
135
136        // subscribe to remaining accounts
137        CloudNotificationBackplane cnb =
138                ExtensionsFactory.getCloudNotificationBackplane();
139        if (cnb.open(context)) {
140            for (String account : accounts) {
141                try {
142                    if (cnb.subscribeToGroup(senderId, account, account)) {
143                        existingAccounts.add(account);
144                    }
145                } catch (IOException e) {
146                    // Try again, next time the account triggers and alert.
147                }
148            }
149            cnb.close();
150            prefs.edit()
151            .putStringSet(ACCOUNT_KEY, existingAccounts)
152            .commit();
153        }
154    }
155
156    /**
157     * Globally dismiss notifications that are backed by the same events.
158     *
159     * @param context application context
160     * @param alarmIds Unique identifiers for events that have been dismissed by the user.
161     * @return true if notification_sender_id is available
162     */
163    public static void dismissGlobally(final Context context, final List<AlarmId> alarmIds) {
164        final String senderId = context.getResources().getString(R.string.notification_sender_id);
165        if ("".equals(senderId)) {
166            Log.i(TAG, "no sender configured");
167            return;
168        }
169        Set<Long> eventIds = new HashSet<Long>(alarmIds.size());
170        for (AlarmId alarmId: alarmIds) {
171            eventIds.add(alarmId.mEventId);
172        }
173        // find the mapping between calendars and events
174        Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
175
176        if (eventsToCalendars.isEmpty()) {
177            Log.d(TAG, "found no calendars for events");
178            return;
179        }
180
181        Set<Long> calendars = new LinkedHashSet<Long>();
182        calendars.addAll(eventsToCalendars.values());
183
184        // find the accounts associated with those calendars
185        Map<Long, Pair<String, String>> calendarsToAccounts =
186                lookupCalendarToAccountMap(context, calendars);
187
188        if (calendarsToAccounts.isEmpty()) {
189            Log.d(TAG, "found no accounts for calendars");
190            return;
191        }
192
193        // TODO group by account to reduce queries
194        Map<String, String> syncIdToAccount = new HashMap<String, String>();
195        Map<Long, String> eventIdToSyncId = new HashMap<Long, String>();
196        ContentResolver resolver = context.getContentResolver();
197        for (Long eventId : eventsToCalendars.keySet()) {
198            Long calendar = eventsToCalendars.get(eventId);
199            Pair<String, String> account = calendarsToAccounts.get(calendar);
200            if (GOOGLE_ACCOUNT_TYPE.equals(account.first)) {
201                Uri uri = asSync(Events.CONTENT_URI, account.first, account.second);
202                Cursor cursor = resolver.query(uri, EVENT_SYNC_PROJECTION,
203                        Events._ID + " = " + eventId, null, null);
204                try {
205                    cursor.moveToPosition(-1);
206                    int sync_id_idx = cursor.getColumnIndex(Events._SYNC_ID);
207                    if (sync_id_idx != -1) {
208                        while (cursor.moveToNext()) {
209                            String syncId = cursor.getString(sync_id_idx);
210                            syncIdToAccount.put(syncId, account.second);
211                            eventIdToSyncId.put(eventId, syncId);
212                        }
213                    }
214                } finally {
215                    cursor.close();
216                }
217            }
218        }
219
220        if (syncIdToAccount.isEmpty()) {
221            Log.d(TAG, "found no syncIds for events");
222            return;
223        }
224
225        // TODO group by account to reduce packets
226        CloudNotificationBackplane cnb = ExtensionsFactory.getCloudNotificationBackplane();
227        if (cnb.open(context)) {
228            for (AlarmId alarmId: alarmIds) {
229                String syncId = eventIdToSyncId.get(alarmId.mEventId);
230                String account = syncIdToAccount.get(syncId);
231                Bundle data = new Bundle();
232                data.putString(SYNC_ID, syncId);
233                data.putString(START_TIME, Long.toString(alarmId.mStart));
234                data.putString(ACCOUNT_NAME, account);
235                try {
236                    cnb.send(account, syncId + ":" + alarmId.mStart, data);
237                } catch (IOException e) {
238                    // TODO save a note to try again later
239                }
240            }
241            cnb.close();
242        }
243    }
244
245    private static Uri asSync(Uri uri, String accountType, String account) {
246        return uri
247                .buildUpon()
248                .appendQueryParameter(
249                        android.provider.CalendarContract.CALLER_IS_SYNCADAPTER, "true")
250                .appendQueryParameter(Calendars.ACCOUNT_NAME, account)
251                .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build();
252    }
253
254    /**
255     * build a selection over a set of row IDs
256     *
257     * @param ids row IDs to select
258     * @param key row name for the table
259     * @return a selection string suitable for a resolver query.
260     */
261    private static String buildMultipleIdQuery(Set<Long> ids, String key) {
262        StringBuilder selection = new StringBuilder();
263        boolean first = true;
264        for (Long id : ids) {
265            if (first) {
266                first = false;
267            } else {
268                selection.append(" OR ");
269            }
270            selection.append(key);
271            selection.append("=");
272            selection.append(id);
273        }
274        return selection.toString();
275    }
276
277    /**
278     * @param context application context
279     * @param eventIds Event row IDs to query.
280     * @return a map from event to calendar
281     */
282    private static Map<Long, Long> lookupEventToCalendarMap(final Context context,
283            final Set<Long> eventIds) {
284        Map<Long, Long> eventsToCalendars = new HashMap<Long, Long>();
285        ContentResolver resolver = context.getContentResolver();
286        String eventSelection = buildMultipleIdQuery(eventIds, Events._ID);
287        Cursor eventCursor = resolver.query(Events.CONTENT_URI, EVENT_PROJECTION,
288                eventSelection, null, null);
289        try {
290            eventCursor.moveToPosition(-1);
291            int calendar_id_idx = eventCursor.getColumnIndex(Events.CALENDAR_ID);
292            int event_id_idx = eventCursor.getColumnIndex(Events._ID);
293            if (calendar_id_idx != -1 && event_id_idx != -1) {
294                while (eventCursor.moveToNext()) {
295                    eventsToCalendars.put(eventCursor.getLong(event_id_idx),
296                            eventCursor.getLong(calendar_id_idx));
297                }
298            }
299        } finally {
300            eventCursor.close();
301        }
302        return eventsToCalendars;
303    }
304
305    /**
306     * @param context application context
307     * @param calendars Calendar row IDs to query.
308     * @return a map from Calendar to a pair (account type, account name)
309     */
310    private static Map<Long, Pair<String, String>> lookupCalendarToAccountMap(final Context context,
311            Set<Long> calendars) {
312        Map<Long, Pair<String, String>> calendarsToAccounts =
313                new HashMap<Long, Pair<String, String>>();
314        ;
315        ContentResolver resolver = context.getContentResolver();
316        String calendarSelection = buildMultipleIdQuery(calendars, Calendars._ID);
317        Cursor calendarCursor = resolver.query(Calendars.CONTENT_URI, CALENDARS_PROJECTION,
318                calendarSelection, null, null);
319        try {
320            calendarCursor.moveToPosition(-1);
321            int calendar_id_idx = calendarCursor.getColumnIndex(Calendars._ID);
322            int account_name_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_NAME);
323            int account_type_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_TYPE);
324            if (calendar_id_idx != -1 && account_name_idx != -1 && account_type_idx != -1) {
325                while (calendarCursor.moveToNext()) {
326                    Long id = calendarCursor.getLong(calendar_id_idx);
327                    String name = calendarCursor.getString(account_name_idx);
328                    String type = calendarCursor.getString(account_type_idx);
329                    calendarsToAccounts.put(id, new Pair<String, String>(type, name));
330                }
331            }
332        } finally {
333            calendarCursor.close();
334        }
335        return calendarsToAccounts;
336    }
337
338    @Override
339    public void onReceive(Context context, Intent intent) {
340        boolean updated = false;
341        if (intent.hasExtra(SYNC_ID) && intent.hasExtra(ACCOUNT_NAME)) {
342            String syncId = intent.getStringExtra(SYNC_ID);
343            long startTime = Long.parseLong(intent.getStringExtra(START_TIME));
344            ContentResolver resolver = context.getContentResolver();
345
346            Uri uri = asSync(Events.CONTENT_URI, GOOGLE_ACCOUNT_TYPE,
347                    intent.getStringExtra(ACCOUNT_NAME));
348            Cursor cursor = resolver.query(uri, EVENT_SYNC_PROJECTION,
349                    Events._SYNC_ID + " = '" + syncId + "'", null, null);
350            try {
351                int event_id_idx = cursor.getColumnIndex(Events._ID);
352                cursor.moveToFirst();
353                if (event_id_idx != -1 && !cursor.isAfterLast()) {
354                    long eventId = cursor.getLong(event_id_idx);
355                    ContentValues values = new ContentValues();
356                    String selection = CalendarAlerts.STATE + "=" + CalendarAlerts.STATE_FIRED +
357                            " AND " + CalendarAlerts.EVENT_ID + "=" + eventId +
358                            " AND " + CalendarAlerts.BEGIN + "=" + startTime;
359                    values.put(CalendarAlerts.STATE, CalendarAlerts.STATE_DISMISSED);
360                    if (resolver.update(CalendarAlerts.CONTENT_URI, values, selection, null) > 0) {
361                        updated |= true;
362                    }
363                }
364            } finally {
365                cursor.close();
366            }
367        }
368
369        if (updated) {
370            Log.d(TAG, "updating alarm state");
371            AlertService.updateAlertNotification(context);
372        }
373    }
374}
375