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