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.Iterator;
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 class GlobalDismissId {
53        public final String mAccountName;
54        public final String mSyncId;
55        public final long mStartTime;
56
57        private GlobalDismissId(String accountName, String syncId, long startTime) {
58            // TODO(psliwowski): Add guava library to use Preconditions class
59            if (accountName == null) {
60                throw new IllegalArgumentException("Account Name can not be set to null");
61            } else if (syncId == null) {
62                throw new IllegalArgumentException("SyncId can not be set to null");
63            }
64            mAccountName = accountName;
65            mSyncId = syncId;
66            mStartTime = startTime;
67        }
68
69        @Override
70        public boolean equals(Object o) {
71            if (this == o) {
72                return true;
73            }
74            if (o == null || getClass() != o.getClass()) {
75                return false;
76            }
77
78            GlobalDismissId that = (GlobalDismissId) o;
79
80            if (mStartTime != that.mStartTime) {
81                return false;
82            }
83            if (!mAccountName.equals(that.mAccountName)) {
84                return false;
85            }
86            if (!mSyncId.equals(that.mSyncId)) {
87                return false;
88            }
89
90            return true;
91        }
92
93        @Override
94        public int hashCode() {
95            int result = mAccountName.hashCode();
96            result = 31 * result + mSyncId.hashCode();
97            result = 31 * result + (int) (mStartTime ^ (mStartTime >>> 32));
98            return result;
99        }
100    }
101
102    public static class LocalDismissId {
103        public final String mAccountType;
104        public final String mAccountName;
105        public final long mEventId;
106        public final long mStartTime;
107
108        public LocalDismissId(String accountType, String accountName, long eventId,
109                long startTime) {
110            if (accountType == null) {
111                throw new IllegalArgumentException("Account Type can not be null");
112            } else if (accountName == null) {
113                throw new IllegalArgumentException("Account Name can not be null");
114            }
115
116            mAccountType = accountType;
117            mAccountName = accountName;
118            mEventId = eventId;
119            mStartTime = startTime;
120        }
121
122        @Override
123        public boolean equals(Object o) {
124            if (this == o) {
125                return true;
126            }
127            if (o == null || getClass() != o.getClass()) {
128                return false;
129            }
130
131            LocalDismissId that = (LocalDismissId) o;
132
133            if (mEventId != that.mEventId) {
134                return false;
135            }
136            if (mStartTime != that.mStartTime) {
137                return false;
138            }
139            if (!mAccountName.equals(that.mAccountName)) {
140                return false;
141            }
142            if (!mAccountType.equals(that.mAccountType)) {
143                return false;
144            }
145
146            return true;
147        }
148
149        @Override
150        public int hashCode() {
151            int result = mAccountType.hashCode();
152            result = 31 * result + mAccountName.hashCode();
153            result = 31 * result + (int) (mEventId ^ (mEventId >>> 32));
154            result = 31 * result + (int) (mStartTime ^ (mStartTime >>> 32));
155            return result;
156        }
157    }
158
159    public static class AlarmId {
160        public long mEventId;
161        public long mStart;
162
163        public AlarmId(long id, long start) {
164            mEventId = id;
165            mStart = start;
166        }
167    }
168
169    private static final long TIME_TO_LIVE = 1 * 60 * 60 * 1000; // 1 hour
170
171    private static final String TAG = "GlobalDismissManager";
172    private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
173    private static final String GLOBAL_DISMISS_MANAGER_PREFS = "com.android.calendar.alerts.GDM";
174    private static final String ACCOUNT_KEY = "known_accounts";
175
176    static final String[] EVENT_PROJECTION = new String[] {
177            Events._ID,
178            Events.CALENDAR_ID
179    };
180    static final String[] EVENT_SYNC_PROJECTION = new String[] {
181            Events._ID,
182            Events._SYNC_ID
183    };
184    static final String[] CALENDARS_PROJECTION = new String[] {
185            Calendars._ID,
186            Calendars.ACCOUNT_NAME,
187            Calendars.ACCOUNT_TYPE
188    };
189
190    public static final String KEY_PREFIX = "com.android.calendar.alerts.";
191    public static final String SYNC_ID = KEY_PREFIX + "sync_id";
192    public static final String START_TIME = KEY_PREFIX + "start_time";
193    public static final String ACCOUNT_NAME = KEY_PREFIX + "account_name";
194    public static final String DISMISS_INTENT = KEY_PREFIX + "DISMISS";
195
196    // TODO(psliwowski): Look into persisting these like AlertUtils.ALERTS_SHARED_PREFS_NAME
197    private static HashMap<GlobalDismissId, Long> sReceiverDismissCache =
198            new HashMap<GlobalDismissId, Long>();
199    private static HashMap<LocalDismissId, Long> sSenderDismissCache =
200            new HashMap<LocalDismissId, Long>();
201
202    /**
203     * Look for unknown accounts in a set of events and associate with them.
204     * Must not be called on main thread.
205     *
206     * @param context application context
207     * @param eventIds IDs for events that have posted notifications that may be
208     *            dismissed.
209     */
210    public static void processEventIds(Context context, Set<Long> eventIds) {
211        final String senderId = context.getResources().getString(R.string.notification_sender_id);
212        if (senderId == null || senderId.isEmpty()) {
213            Log.i(TAG, "no sender configured");
214            return;
215        }
216        Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
217        Set<Long> calendars = new LinkedHashSet<Long>();
218        calendars.addAll(eventsToCalendars.values());
219        if (calendars.isEmpty()) {
220            Log.d(TAG, "found no calendars for events");
221            return;
222        }
223
224        Map<Long, Pair<String, String>> calendarsToAccounts =
225                lookupCalendarToAccountMap(context, calendars);
226
227        if (calendarsToAccounts.isEmpty()) {
228            Log.d(TAG, "found no accounts for calendars");
229            return;
230        }
231
232        // filter out non-google accounts (necessary?)
233        Set<String> accounts = new LinkedHashSet<String>();
234        for (Pair<String, String> accountPair : calendarsToAccounts.values()) {
235            if (GOOGLE_ACCOUNT_TYPE.equals(accountPair.first)) {
236                accounts.add(accountPair.second);
237            }
238        }
239
240        // filter out accounts we already know about
241        SharedPreferences prefs =
242                context.getSharedPreferences(GLOBAL_DISMISS_MANAGER_PREFS,
243                        Context.MODE_PRIVATE);
244        Set<String> existingAccounts = prefs.getStringSet(ACCOUNT_KEY,
245                new HashSet<String>());
246        accounts.removeAll(existingAccounts);
247
248        if (accounts.isEmpty()) {
249            // nothing to do, we've already registered all the accounts.
250            return;
251        }
252
253        // subscribe to remaining accounts
254        CloudNotificationBackplane cnb =
255                ExtensionsFactory.getCloudNotificationBackplane();
256        if (cnb.open(context)) {
257            for (String account : accounts) {
258                try {
259                    if (cnb.subscribeToGroup(senderId, account, account)) {
260                        existingAccounts.add(account);
261                    }
262                } catch (IOException e) {
263                    // Try again, next time the account triggers and alert.
264                }
265            }
266            cnb.close();
267            prefs.edit()
268            .putStringSet(ACCOUNT_KEY, existingAccounts)
269            .commit();
270        }
271    }
272
273    /**
274     * Some events don't have a global sync_id when they are dismissed. We need to wait
275     * until the data provider is updated before we can send the global dismiss message.
276     */
277    public static void syncSenderDismissCache(Context context) {
278        final String senderId = context.getResources().getString(R.string.notification_sender_id);
279        if ("".equals(senderId)) {
280            Log.i(TAG, "no sender configured");
281            return;
282        }
283        CloudNotificationBackplane cnb = ExtensionsFactory.getCloudNotificationBackplane();
284        if (!cnb.open(context)) {
285            Log.i(TAG, "Unable to open cloud notification backplane");
286
287        }
288
289        long currentTime = System.currentTimeMillis();
290        ContentResolver resolver = context.getContentResolver();
291        synchronized (sSenderDismissCache) {
292            Iterator<Map.Entry<LocalDismissId, Long>> it =
293                    sSenderDismissCache.entrySet().iterator();
294            while (it.hasNext()) {
295                Map.Entry<LocalDismissId, Long> entry = it.next();
296                LocalDismissId dismissId = entry.getKey();
297
298                Uri uri = asSync(Events.CONTENT_URI, dismissId.mAccountType,
299                        dismissId.mAccountName);
300                Cursor cursor = resolver.query(uri, EVENT_SYNC_PROJECTION,
301                        Events._ID + " = " + dismissId.mEventId, null, null);
302                try {
303                    cursor.moveToPosition(-1);
304                    int sync_id_idx = cursor.getColumnIndex(Events._SYNC_ID);
305                    if (sync_id_idx != -1) {
306                        while (cursor.moveToNext()) {
307                            String syncId = cursor.getString(sync_id_idx);
308                            if (syncId != null) {
309                                Bundle data = new Bundle();
310                                long startTime = dismissId.mStartTime;
311                                String accountName = dismissId.mAccountName;
312                                data.putString(SYNC_ID, syncId);
313                                data.putString(START_TIME, Long.toString(startTime));
314                                data.putString(ACCOUNT_NAME, accountName);
315                                try {
316                                    cnb.send(accountName, syncId + ":" + startTime, data);
317                                    it.remove();
318                                } catch (IOException e) {
319                                    // If we couldn't send, then leave dismissal in cache
320                                }
321                            }
322                        }
323                    }
324                } finally {
325                    cursor.close();
326                }
327
328                // Remove old dismissals from cache after a certain time period
329                if (currentTime - entry.getValue() > TIME_TO_LIVE) {
330                    it.remove();
331                }
332            }
333        }
334
335        cnb.close();
336    }
337
338    /**
339     * Globally dismiss notifications that are backed by the same events.
340     *
341     * @param context application context
342     * @param alarmIds Unique identifiers for events that have been dismissed by the user.
343     * @return true if notification_sender_id is available
344     */
345    public static void dismissGlobally(Context context, List<AlarmId> alarmIds) {
346        Set<Long> eventIds = new HashSet<Long>(alarmIds.size());
347        for (AlarmId alarmId: alarmIds) {
348            eventIds.add(alarmId.mEventId);
349        }
350        // find the mapping between calendars and events
351        Map<Long, Long> eventsToCalendars = lookupEventToCalendarMap(context, eventIds);
352        if (eventsToCalendars.isEmpty()) {
353            Log.d(TAG, "found no calendars for events");
354            return;
355        }
356
357        Set<Long> calendars = new LinkedHashSet<Long>();
358        calendars.addAll(eventsToCalendars.values());
359
360        // find the accounts associated with those calendars
361        Map<Long, Pair<String, String>> calendarsToAccounts =
362                lookupCalendarToAccountMap(context, calendars);
363        if (calendarsToAccounts.isEmpty()) {
364            Log.d(TAG, "found no accounts for calendars");
365            return;
366        }
367
368        long currentTime = System.currentTimeMillis();
369        for (AlarmId alarmId : alarmIds) {
370            Long calendar = eventsToCalendars.get(alarmId.mEventId);
371            Pair<String, String> account = calendarsToAccounts.get(calendar);
372            if (GOOGLE_ACCOUNT_TYPE.equals(account.first)) {
373                LocalDismissId dismissId = new LocalDismissId(account.first, account.second,
374                        alarmId.mEventId, alarmId.mStart);
375                synchronized (sSenderDismissCache) {
376                    sSenderDismissCache.put(dismissId, currentTime);
377                }
378            }
379        }
380        syncSenderDismissCache(context);
381    }
382
383    private static Uri asSync(Uri uri, String accountType, String account) {
384        return uri
385                .buildUpon()
386                .appendQueryParameter(
387                        android.provider.CalendarContract.CALLER_IS_SYNCADAPTER, "true")
388                .appendQueryParameter(Calendars.ACCOUNT_NAME, account)
389                .appendQueryParameter(Calendars.ACCOUNT_TYPE, accountType).build();
390    }
391
392    /**
393     * Build a selection over a set of row IDs
394     *
395     * @param ids row IDs to select
396     * @param key row name for the table
397     * @return a selection string suitable for a resolver query.
398     */
399    private static String buildMultipleIdQuery(Set<Long> ids, String key) {
400        StringBuilder selection = new StringBuilder();
401        boolean first = true;
402        for (Long id : ids) {
403            if (first) {
404                first = false;
405            } else {
406                selection.append(" OR ");
407            }
408            selection.append(key);
409            selection.append("=");
410            selection.append(id);
411        }
412        return selection.toString();
413    }
414
415    /**
416     * @param context application context
417     * @param eventIds Event row IDs to query.
418     * @return a map from event to calendar
419     */
420    private static Map<Long, Long> lookupEventToCalendarMap(Context context, Set<Long> eventIds) {
421        Map<Long, Long> eventsToCalendars = new HashMap<Long, Long>();
422        ContentResolver resolver = context.getContentResolver();
423        String eventSelection = buildMultipleIdQuery(eventIds, Events._ID);
424        Cursor eventCursor = resolver.query(Events.CONTENT_URI, EVENT_PROJECTION,
425                eventSelection, null, null);
426        try {
427            eventCursor.moveToPosition(-1);
428            int calendar_id_idx = eventCursor.getColumnIndex(Events.CALENDAR_ID);
429            int event_id_idx = eventCursor.getColumnIndex(Events._ID);
430            if (calendar_id_idx != -1 && event_id_idx != -1) {
431                while (eventCursor.moveToNext()) {
432                    eventsToCalendars.put(eventCursor.getLong(event_id_idx),
433                            eventCursor.getLong(calendar_id_idx));
434                }
435            }
436        } finally {
437            eventCursor.close();
438        }
439        return eventsToCalendars;
440    }
441
442    /**
443     * @param context application context
444     * @param calendars Calendar row IDs to query.
445     * @return a map from Calendar to a pair (account type, account name)
446     */
447    private static Map<Long, Pair<String, String>> lookupCalendarToAccountMap(Context context,
448            Set<Long> calendars) {
449        Map<Long, Pair<String, String>> calendarsToAccounts =
450                new HashMap<Long, Pair<String, String>>();
451        ContentResolver resolver = context.getContentResolver();
452        String calendarSelection = buildMultipleIdQuery(calendars, Calendars._ID);
453        Cursor calendarCursor = resolver.query(Calendars.CONTENT_URI, CALENDARS_PROJECTION,
454                calendarSelection, null, null);
455        try {
456            calendarCursor.moveToPosition(-1);
457            int calendar_id_idx = calendarCursor.getColumnIndex(Calendars._ID);
458            int account_name_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_NAME);
459            int account_type_idx = calendarCursor.getColumnIndex(Calendars.ACCOUNT_TYPE);
460            if (calendar_id_idx != -1 && account_name_idx != -1 && account_type_idx != -1) {
461                while (calendarCursor.moveToNext()) {
462                    Long id = calendarCursor.getLong(calendar_id_idx);
463                    String name = calendarCursor.getString(account_name_idx);
464                    String type = calendarCursor.getString(account_type_idx);
465                    if (name != null && type != null) {
466                        calendarsToAccounts.put(id, new Pair<String, String>(type, name));
467                    }
468                }
469            }
470        } finally {
471            calendarCursor.close();
472        }
473        return calendarsToAccounts;
474    }
475
476    /**
477     * We can get global dismisses for events we don't know exists yet, so sync our cache
478     * with the data provider whenever it updates.
479     */
480    public static void syncReceiverDismissCache(Context context) {
481        ContentResolver resolver = context.getContentResolver();
482        long currentTime = System.currentTimeMillis();
483        synchronized (sReceiverDismissCache) {
484            Iterator<Map.Entry<GlobalDismissId, Long>> it =
485                    sReceiverDismissCache.entrySet().iterator();
486            while (it.hasNext()) {
487                Map.Entry<GlobalDismissId, Long> entry = it.next();
488                GlobalDismissId globalDismissId = entry.getKey();
489                Uri uri = GlobalDismissManager.asSync(Events.CONTENT_URI,
490                        GlobalDismissManager.GOOGLE_ACCOUNT_TYPE, globalDismissId.mAccountName);
491                Cursor cursor = resolver.query(uri, GlobalDismissManager.EVENT_SYNC_PROJECTION,
492                        Events._SYNC_ID + " = '" + globalDismissId.mSyncId + "'",
493                        null, null);
494                try {
495                    int event_id_idx = cursor.getColumnIndex(Events._ID);
496                    cursor.moveToFirst();
497                    if (event_id_idx != -1 && !cursor.isAfterLast()) {
498                        long eventId = cursor.getLong(event_id_idx);
499                        ContentValues values = new ContentValues();
500                        String selection = "(" + CalendarAlerts.STATE + "=" +
501                                CalendarAlerts.STATE_FIRED + " OR " +
502                                CalendarAlerts.STATE + "=" +
503                                CalendarAlerts.STATE_SCHEDULED + ") AND " +
504                                CalendarAlerts.EVENT_ID + "=" + eventId + " AND " +
505                                CalendarAlerts.BEGIN + "=" + globalDismissId.mStartTime;
506                        values.put(CalendarAlerts.STATE, CalendarAlerts.STATE_DISMISSED);
507                        int rows = resolver.update(CalendarAlerts.CONTENT_URI, values,
508                                selection, null);
509                        if (rows > 0) {
510                            it.remove();
511                        }
512                    }
513                } finally {
514                    cursor.close();
515                }
516
517                if (currentTime - entry.getValue() > TIME_TO_LIVE) {
518                    it.remove();
519                }
520            }
521        }
522    }
523
524    @Override
525    @SuppressWarnings("unchecked")
526    public void onReceive(Context context, Intent intent) {
527        new AsyncTask<Pair<Context, Intent>, Void, Void>() {
528            @Override
529            protected Void doInBackground(Pair<Context, Intent>... params) {
530                Context context = params[0].first;
531                Intent intent = params[0].second;
532                if (intent.hasExtra(SYNC_ID) && intent.hasExtra(ACCOUNT_NAME)
533                        && intent.hasExtra(START_TIME)) {
534                    synchronized (sReceiverDismissCache) {
535                        sReceiverDismissCache.put(new GlobalDismissId(
536                                intent.getStringExtra(ACCOUNT_NAME),
537                                intent.getStringExtra(SYNC_ID),
538                                Long.parseLong(intent.getStringExtra(START_TIME))
539                        ), System.currentTimeMillis());
540                    }
541                    AlertService.updateAlertNotification(context);
542                }
543                return null;
544            }
545        }.execute(new Pair<Context, Intent>(context, intent));
546    }
547}
548