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