GlobalDismissManager.java revision 08f6c0cd717d9ce5197028e81c469b27ae9f6cf3
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 SYNC_ID = "sync_id";
73    public static final String START_TIME = "start_time";
74    public static final String ACCOUNT_NAME = "account_name";  // redundant?
75    public static final String DISMISS_INTENT = "com.android.calendar.alerts.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        new AsyncTask<Void, Void, Void>() {
101
102            @Override
103            protected Void doInBackground(Void... params) {
104
105                Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
106                Set<Long> calendars = new LinkedHashSet<Long>();
107                calendars.addAll(eventsToCalendars.values());
108                if (calendars.isEmpty()) {
109                    Log.d(TAG, "foudn no calendars for events");
110                    return null;
111                }
112
113                Map<Long, Pair<String, String>> calendarsToAccounts =
114                        lookupCalendarToAccountMap(context, calendars);
115
116                if (calendarsToAccounts.isEmpty()) {
117                    Log.d(TAG, "found no accounts for calendars");
118                    return null;
119                }
120
121                // filter out non-google accounts (necessary?)
122                Set<String> accounts = new LinkedHashSet<String>();
123                for (Pair<String, String> accountPair : calendarsToAccounts.values()) {
124                    if (GOOGLE_ACCOUNT_TYPE.equals(accountPair.first)) {
125                        accounts.add(accountPair.second);
126                    }
127                }
128
129                // filter out accounts we already know about
130                SharedPreferences prefs =
131                        context.getSharedPreferences(GLOBAL_DISMISS_MANAGER_PREFS,
132                                Context.MODE_PRIVATE);
133                Set<String> existingAccounts = prefs.getStringSet(ACCOUNT_KEY,
134                        new HashSet<String>());
135                accounts.removeAll(existingAccounts);
136
137                if (accounts.isEmpty()) {
138                    return null;
139                }
140
141                // subscribe to remaining accounts
142                CloudNotificationBackplane cnb =
143                        ExtensionsFactory.getCloudNotificationBackplane();
144                if (cnb.open(context)) {
145                    for (String account : accounts) {
146                        try {
147                            cnb.subscribeToGroup(senderId, account, account);
148                            accounts.add(account);
149                        } catch (IOException e) {
150                            // Try again, next time the account triggers and alert.
151                        }
152                    }
153                    cnb.close();
154                    prefs.edit()
155                    .putStringSet(ACCOUNT_KEY, accounts)
156                    .commit();
157                }
158                return null;
159            }
160        }.execute();
161    }
162
163    /**
164     * Globally dismiss notifications that are backed by the same events.
165     *
166     * @param context application context
167     * @param alarmIds Unique identifiers for events that have been dismissed by the user.
168     * @return true if notification_sender_id is available
169     */
170    public static void dismissGlobally(final Context context, final List<AlarmId> alarmIds) {
171        final String senderId = context.getResources().getString(R.string.notification_sender_id);
172        if ("".equals(senderId)) {
173            Log.i(TAG, "no sender configured");
174            return;
175        }
176        new AsyncTask<Void, Void, Void>() {
177            @Override
178            protected Void doInBackground(Void... params) {
179                Set<Long> eventIds = new HashSet<Long>(alarmIds.size());
180                for (AlarmId alarmId: alarmIds) {
181                    eventIds.add(alarmId.mEventId);
182                }
183                // find the mapping between calendars and events
184                Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
185
186                if (eventsToCalendars.isEmpty()) {
187                    Log.d(TAG, "found no calendars for events");
188                    return null;
189                }
190
191                Set<Long> calendars = new LinkedHashSet<Long>();
192                calendars.addAll(eventsToCalendars.values());
193
194                // find the accounts associated with those calendars
195                Map<Long, Pair<String, String>> calendarsToAccounts =
196                        lookupCalendarToAccountMap(context, calendars);
197
198                if (calendarsToAccounts.isEmpty()) {
199                    Log.d(TAG, "found no accounts for calendars");
200                    return null;
201                }
202
203                // TODO group by account to reduce queries
204                Map<String, String> syncIdToAccount = new HashMap<String, String>();
205                Map<Long, String> eventIdToSyncId = new HashMap<Long, String>();
206                ContentResolver resolver = context.getContentResolver();
207                for (Long eventId : eventsToCalendars.keySet()) {
208                    Long calendar = eventsToCalendars.get(eventId);
209                    Pair<String, String> account = calendarsToAccounts.get(calendar);
210                    if (GOOGLE_ACCOUNT_TYPE.equals(account.first)) {
211                        Uri uri = asSync(Events.CONTENT_URI, account.first, account.second);
212                        Cursor cursor = resolver.query(uri, EVENT_SYNC_PROJECTION,
213                                Events._ID + " = " + eventId, null, null);
214                        try {
215                            cursor.moveToPosition(-1);
216                            int sync_id_idx = cursor.getColumnIndex(Events._SYNC_ID);
217                            if (sync_id_idx != -1) {
218                                while (cursor.moveToNext()) {
219                                    String syncId = cursor.getString(sync_id_idx);
220                                    syncIdToAccount.put(syncId, account.second);
221                                    eventIdToSyncId.put(eventId, syncId);
222                                }
223                            }
224                        } finally {
225                            cursor.close();
226                        }
227                    }
228                }
229
230                if (syncIdToAccount.isEmpty()) {
231                    Log.d(TAG, "found no syncIds for events");
232                    return null;
233                }
234
235                // TODO group by account to reduce packets
236                CloudNotificationBackplane cnb = ExtensionsFactory.getCloudNotificationBackplane();
237                if (cnb.open(context)) {
238                    for (AlarmId alarmId: alarmIds) {
239                        String syncId = eventIdToSyncId.get(alarmId.mEventId);
240                        String account = syncIdToAccount.get(syncId);
241                        Bundle data = new Bundle();
242                        data.putString(SYNC_ID, syncId);
243                        data.putLong(START_TIME, alarmId.mStart);
244                        data.putString(ACCOUNT_NAME, account);
245                        try {
246                            cnb.send(account,
247                                    syncId + ":" + alarmId.mStart,
248                                    syncId, FOUR_WEEKS, 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 = intent.getLongExtra(START_TIME, 0L);
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        setResultCode(Activity.RESULT_OK);
390    }
391}
392