MailService.java revision 9b9a2e69b920823c18b27740ee77cef007316d60
1/*
2 * Copyright (C) 2008 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.email.service;
18
19import com.android.email.Controller;
20import com.android.email.Email;
21import com.android.email.R;
22import com.android.email.activity.MessageList;
23import com.android.email.mail.MessagingException;
24import com.android.email.provider.EmailContent.Account;
25import com.android.email.provider.EmailContent.AccountColumns;
26import com.android.email.provider.EmailContent.Mailbox;
27
28import android.app.AlarmManager;
29import android.app.Notification;
30import android.app.NotificationManager;
31import android.app.PendingIntent;
32import android.app.Service;
33import android.content.ContentUris;
34import android.content.ContentValues;
35import android.content.Context;
36import android.content.Intent;
37import android.database.Cursor;
38import android.net.Uri;
39import android.os.IBinder;
40import android.os.SystemClock;
41import android.util.Config;
42import android.util.Log;
43
44import java.util.HashMap;
45
46/**
47 * Background service for refreshing non-push email accounts.
48 */
49public class MailService extends Service {
50    /** DO NOT CHECK IN "TRUE" */
51    private static final boolean DEBUG_FORCE_QUICK_REFRESH = false;        // force 1-minute refresh
52    private static final String LOG_TAG = "Email-MailService";
53
54    public static int NEW_MESSAGE_NOTIFICATION_ID = 1;
55
56    private static final String ACTION_CHECK_MAIL =
57        "com.android.email.intent.action.MAIL_SERVICE_WAKEUP";
58    private static final String ACTION_RESCHEDULE =
59        "com.android.email.intent.action.MAIL_SERVICE_RESCHEDULE";
60    private static final String ACTION_CANCEL =
61        "com.android.email.intent.action.MAIL_SERVICE_CANCEL";
62    private static final String ACTION_NOTIFY_MAIL =
63        "com.android.email.intent.action.MAIL_SERVICE_NOTIFY";
64
65    private static final String EXTRA_CHECK_ACCOUNT = "com.android.email.intent.extra.ACCOUNT";
66    private static final String EXTRA_ACCOUNT_INFO = "com.android.email.intent.extra.ACCOUNT_INFO";
67
68    private static final String[] NEW_MESSAGE_COUNT_PROJECTION =
69        new String[] {AccountColumns.NEW_MESSAGE_COUNT};
70
71    private Controller.Result mControllerCallback = new ControllerResults();
72
73    private int mStartId;
74
75    /**
76     * Access must be synchronized, because there are accesses from the Controller callback
77     */
78    private static HashMap<Long,AccountSyncReport> mSyncReports =
79        new HashMap<Long,AccountSyncReport>();
80
81    /**
82     * Simple template used for clearing new message count in accounts
83     */
84    static ContentValues mClearNewMessages;
85    static {
86        mClearNewMessages = new ContentValues();
87        mClearNewMessages.put(Account.NEW_MESSAGE_COUNT, 0);
88    }
89
90    public static void actionReschedule(Context context) {
91        Intent i = new Intent();
92        i.setClass(context, MailService.class);
93        i.setAction(MailService.ACTION_RESCHEDULE);
94        context.startService(i);
95    }
96
97    public static void actionCancel(Context context)  {
98        Intent i = new Intent();
99        i.setClass(context, MailService.class);
100        i.setAction(MailService.ACTION_CANCEL);
101        context.startService(i);
102    }
103
104    /**
105     * Reset new message counts for one or all accounts.  This clears both our local copy and
106     * the values (if any) stored in the account records.
107     *
108     * @param accountId account to clear, or -1 for all accounts
109     */
110    public static void resetNewMessageCount(Context context, long accountId) {
111        synchronized (mSyncReports) {
112            for (AccountSyncReport report : mSyncReports.values()) {
113                if (accountId == -1 || accountId == report.accountId) {
114                    report.numNewMessages = 0;
115                }
116            }
117        }
118        // now do the database - all accounts, or just one of them
119        Uri uri;
120        if (accountId == -1) {
121            uri = Account.CONTENT_URI;
122        } else {
123            uri = ContentUris.withAppendedId(Account.CONTENT_URI, accountId);
124        }
125        context.getContentResolver().update(uri, mClearNewMessages, null, null);
126    }
127
128    /**
129     * Entry point for asynchronous message services (e.g. push mode) to post notifications of new
130     * messages.  This assumes that the push provider has already synced the messages into the
131     * appropriate database - this simply triggers the notification mechanism.
132     *
133     * @param context a context
134     * @param accountId the id of the account that is reporting new messages
135     * @param newCount the number of new messages
136     */
137    public static void actionNotifyNewMessages(Context context, long accountId) {
138        Intent i = new Intent(ACTION_NOTIFY_MAIL);
139        i.setClass(context, MailService.class);
140        i.putExtra(EXTRA_CHECK_ACCOUNT, accountId);
141        context.startService(i);
142    }
143
144    @Override
145    public int onStartCommand(Intent intent, int flags, int startId) {
146        super.onStartCommand(intent, flags, startId);
147
148        // TODO this needs to be passed through the controller and back to us
149        this.mStartId = startId;
150        String action = intent.getAction();
151
152        Controller controller = Controller.getInstance(getApplication());
153        controller.addResultCallback(mControllerCallback);
154
155        if (ACTION_CHECK_MAIL.equals(action)) {
156            // If we have the data, restore the last-sync-times for each account
157            // These are cached in the wakeup intent in case the process was killed.
158            restoreSyncReports(intent);
159
160            // Sync a specific account if given
161            long checkAccountId = intent.getLongExtra(EXTRA_CHECK_ACCOUNT, -1);
162            if (Config.LOGD && Email.DEBUG) {
163                Log.d(LOG_TAG, "action: check mail for id=" + checkAccountId);
164            }
165            if (checkAccountId != -1) {
166                // launch an account sync in the controller
167                syncOneAccount(controller, checkAccountId, startId);
168            } else {
169                // Find next account to sync, and reschedule
170                AlarmManager alarmManager = (AlarmManager)getSystemService(Context.ALARM_SERVICE);
171                reschedule(alarmManager);
172                stopSelf(startId);
173            }
174        }
175        else if (ACTION_CANCEL.equals(action)) {
176            if (Config.LOGD && Email.DEBUG) {
177                Log.d(LOG_TAG, "action: cancel");
178            }
179            cancel();
180            stopSelf(startId);
181        }
182        else if (ACTION_RESCHEDULE.equals(action)) {
183            if (Config.LOGD && Email.DEBUG) {
184                Log.d(LOG_TAG, "action: reschedule");
185            }
186            AlarmManager alarmManager = (AlarmManager)getSystemService(Context.ALARM_SERVICE);
187            reschedule(alarmManager);
188            stopSelf(startId);
189        } else if (ACTION_NOTIFY_MAIL.equals(action)) {
190            long accountId = intent.getLongExtra(EXTRA_CHECK_ACCOUNT, -1);
191            // Get the current new message count
192            Cursor c = getContentResolver().query(
193                    ContentUris.withAppendedId(Account.CONTENT_URI, accountId),
194                    NEW_MESSAGE_COUNT_PROJECTION, null, null, null);
195            int newMessageCount = 0;
196            try {
197                if (c.moveToFirst()) {
198                    newMessageCount = c.getInt(0);
199                } else {
200                    // If the account no longer exists, set to -1 (which is handled below)
201                    accountId = -1;
202                }
203            } finally {
204                c.close();
205            }
206            if (Config.LOGD && Email.DEBUG) {
207                Log.d(LOG_TAG, "notify accountId=" + Long.toString(accountId)
208                        + " count=" + newMessageCount);
209            }
210            if (accountId != -1) {
211                updateAccountReport(accountId, newMessageCount);
212                notifyNewMessages(accountId);
213            }
214            stopSelf(startId);
215        }
216
217        // If we get killed will syncing, have the intent sent to us again.
218        // Evetually we should change to schedule the next alarm in this
219        // function, and return START_NOT_STICK from here.
220        return START_REDELIVER_INTENT;
221    }
222
223    @Override
224    public IBinder onBind(Intent intent) {
225        return null;
226    }
227
228    @Override
229    public void onDestroy() {
230        super.onDestroy();
231        Controller.getInstance(getApplication()).removeResultCallback(mControllerCallback);
232    }
233
234    private void cancel() {
235        AlarmManager alarmMgr = (AlarmManager)getSystemService(Context.ALARM_SERVICE);
236        PendingIntent pi = createAlarmIntent(-1, null);
237        alarmMgr.cancel(pi);
238    }
239
240    /**
241     * Create and send an alarm with the entire list.  This also sends a list of known last-sync
242     * times with the alarm, so if we are killed between alarms, we don't lose this info.
243     *
244     * @param alarmMgr passed in so we can mock for testing.
245     */
246    /* package */ void reschedule(AlarmManager alarmMgr) {
247        // restore the reports if lost
248        setupSyncReports(-1);
249        synchronized (mSyncReports) {
250            int numAccounts = mSyncReports.size();
251            long[] accountInfo = new long[numAccounts * 2];     // pairs of { accountId, lastSync }
252            int accountInfoIndex = 0;
253
254            long nextCheckTime = Long.MAX_VALUE;
255            AccountSyncReport nextAccount = null;
256            long timeNow = SystemClock.elapsedRealtime();
257
258            for (AccountSyncReport report : mSyncReports.values()) {
259                if (report.syncInterval <= 0) {                         // no timed checks - skip
260                    continue;
261                }
262                // select next account to sync
263                if ((report.prevSyncTime == 0)                          // never checked
264                        || (report.nextSyncTime < timeNow)) {           // overdue
265                    nextCheckTime = 0;
266                    nextAccount = report;
267                } else if (report.nextSyncTime < nextCheckTime) {       // next to be checked
268                    nextCheckTime = report.nextSyncTime;
269                    nextAccount = report;
270                }
271                // collect last-sync-times for all accounts
272                // this is using pairs of {long,long} to simplify passing in a bundle
273                accountInfo[accountInfoIndex++] = report.accountId;
274                accountInfo[accountInfoIndex++] = report.prevSyncTime;
275            }
276
277            // set/clear alarm as needed
278            long idToCheck = (nextAccount == null) ? -1 : nextAccount.accountId;
279            PendingIntent pi = createAlarmIntent(idToCheck, accountInfo);
280
281            if (nextAccount == null) {
282                alarmMgr.cancel(pi);
283                if (Config.LOGD && Email.DEBUG) {
284                    Log.d(LOG_TAG, "reschedule: alarm cancel - no account to check");
285                }
286            } else {
287                alarmMgr.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, nextCheckTime, pi);
288                if (Config.LOGD && Email.DEBUG) {
289                    Log.d(LOG_TAG, "reschedule: alarm set at " + nextCheckTime
290                            + " for " + nextAccount);
291                }
292            }
293        }
294    }
295
296    /**
297     * Return a pending intent for use by this alarm.  Most of the fields must be the same
298     * (in order for the intent to be recognized by the alarm manager) but the extras can
299     * be different, and are passed in here as parameters.
300     */
301    /* package */ PendingIntent createAlarmIntent(long checkId, long[] accountInfo) {
302        Intent i = new Intent();
303        i.setClassName("com.android.email", "com.android.email.service.MailService");
304        i.setAction(ACTION_CHECK_MAIL);
305        i.putExtra(EXTRA_CHECK_ACCOUNT, checkId);
306        i.putExtra(EXTRA_ACCOUNT_INFO, accountInfo);
307        PendingIntent pi = PendingIntent.getService(this, 0, i, 0);
308        return pi;
309    }
310
311    /**
312     * Start a controller sync for a specific account
313     */
314    private void syncOneAccount(Controller controller, long checkAccountId, int startId) {
315        long inboxId = Mailbox.findMailboxOfType(this, checkAccountId, Mailbox.TYPE_INBOX);
316        if (inboxId == Mailbox.NO_MAILBOX) {
317            // no inbox??  sync mailboxes
318        } else {
319            controller.serviceCheckMail(checkAccountId, inboxId, startId, mControllerCallback);
320        }
321    }
322
323    /**
324     * Note:  Times are relative to SystemClock.elapsedRealtime()
325     */
326    private static class AccountSyncReport {
327        long accountId;
328        long prevSyncTime;      // 0 == unknown
329        long nextSyncTime;      // 0 == ASAP  -1 == don't sync
330        int numNewMessages;
331
332        int syncInterval;
333        boolean notify;
334        boolean vibrate;
335        Uri ringtoneUri;
336
337        String displayName;     // temporary, for debug logging
338
339
340        @Override
341        public String toString() {
342            return displayName + ": prevSync=" + prevSyncTime + " nextSync=" + nextSyncTime
343                    + " numNew=" + numNewMessages;
344        }
345    }
346
347    /**
348     * scan accounts to create a list of { acct, prev sync, next sync, #new }
349     * use this to create a fresh copy.  assumes all accounts need sync
350     *
351     * @param accountId -1 will rebuild the list if empty.  other values will force loading
352     *   of a single account (e.g if it was created after the original list population)
353     */
354    /* package */ void setupSyncReports(long accountId) {
355        synchronized (mSyncReports) {
356            if (accountId == -1) {
357                // -1 == reload the list if empty, otherwise exit immediately
358                if (mSyncReports.size() > 0) {
359                    return;
360                }
361            } else {
362                // load a single account if it doesn't already have a sync record
363                if (mSyncReports.containsKey(accountId)) {
364                    return;
365                }
366            }
367
368            // setup to add a single account or all accounts
369            Uri uri;
370            if (accountId == -1) {
371                uri = Account.CONTENT_URI;
372            } else {
373                uri = ContentUris.withAppendedId(Account.CONTENT_URI, accountId);
374            }
375
376            // TODO use a narrower projection here
377            Cursor c = getContentResolver().query(uri, Account.CONTENT_PROJECTION,
378                    null, null, null);
379            try {
380                while (c.moveToNext()) {
381                    AccountSyncReport report = new AccountSyncReport();
382                    int syncInterval = c.getInt(Account.CONTENT_SYNC_INTERVAL_COLUMN);
383                    int flags = c.getInt(Account.CONTENT_FLAGS_COLUMN);
384                    String ringtoneString = c.getString(Account.CONTENT_RINGTONE_URI_COLUMN);
385
386                    // For debugging only
387                    if (DEBUG_FORCE_QUICK_REFRESH && syncInterval >= 0) {
388                        syncInterval = 1;
389                    }
390
391                    report.accountId = c.getLong(Account.CONTENT_ID_COLUMN);
392                    report.prevSyncTime = 0;
393                    report.nextSyncTime = (syncInterval > 0) ? 0 : -1;  // 0 == ASAP -1 == no sync
394                    report.numNewMessages = 0;
395
396                    report.syncInterval = syncInterval;
397                    report.notify = (flags & Account.FLAGS_NOTIFY_NEW_MAIL) != 0;
398                    report.vibrate = (flags & Account.FLAGS_VIBRATE) != 0;
399                    report.ringtoneUri = (ringtoneString == null) ? null
400                                                                  : Uri.parse(ringtoneString);
401
402                    report.displayName = c.getString(Account.CONTENT_DISPLAY_NAME_COLUMN);
403
404                    // TODO lookup # new in inbox
405                    mSyncReports.put(report.accountId, report);
406                }
407            } finally {
408                c.close();
409            }
410        }
411    }
412
413    /**
414     * Update list with a single account's sync times and unread count
415     *
416     * @param accountId the account being udpated
417     * @param newCount the number of new messages, or -1 if not being reported (don't update)
418     * @return the report for the updated account, or null if it doesn't exist (e.g. deleted)
419     */
420    /* package */ AccountSyncReport updateAccountReport(long accountId, int newCount) {
421        // restore the reports if lost
422        setupSyncReports(accountId);
423        synchronized (mSyncReports) {
424            AccountSyncReport report = mSyncReports.get(accountId);
425            if (report == null) {
426                // discard result - there is no longer an account with this id
427                Log.d(LOG_TAG, "No account to update for id=" + Long.toString(accountId));
428                return null;
429            }
430
431            // report found - update it (note - editing the report while in-place in the hashmap)
432            report.prevSyncTime = SystemClock.elapsedRealtime();
433            if (report.syncInterval > 0) {
434                report.nextSyncTime = report.prevSyncTime + (report.syncInterval * 1000 * 60);
435            }
436            if (newCount != -1) {
437                report.numNewMessages = newCount;
438            }
439            if (Config.LOGD && Email.DEBUG) {
440                Log.d(LOG_TAG, "update account " + report.toString());
441            }
442            return report;
443        }
444    }
445
446    /**
447     * when we receive an alarm, update the account sync reports list if necessary
448     * this will be the case when if we have restarted the process and lost the data
449     * in the global.
450     *
451     * @param restoreIntent the intent with the list
452     */
453    /* package */ void restoreSyncReports(Intent restoreIntent) {
454        // restore the reports if lost
455        setupSyncReports(-1);
456        synchronized (mSyncReports) {
457            long[] accountInfo = restoreIntent.getLongArrayExtra(EXTRA_ACCOUNT_INFO);
458            if (accountInfo == null) {
459                Log.d(LOG_TAG, "no data in intent to restore");
460                return;
461            }
462            int accountInfoIndex = 0;
463            int accountInfoLimit = accountInfo.length;
464            while (accountInfoIndex < accountInfoLimit) {
465                long accountId = accountInfo[accountInfoIndex++];
466                long prevSync = accountInfo[accountInfoIndex++];
467                AccountSyncReport report = mSyncReports.get(accountId);
468                if (report != null) {
469                    if (report.prevSyncTime == 0) {
470                        report.prevSyncTime = prevSync;
471                        if (Config.LOGD && Email.DEBUG) {
472                            Log.d(LOG_TAG, "restore prev sync for account" + report);
473                        }
474                    }
475                }
476            }
477        }
478    }
479
480    class ControllerResults implements Controller.Result {
481
482        public void loadMessageForViewCallback(MessagingException result, long messageId,
483                int progress) {
484        }
485
486        public void loadAttachmentCallback(MessagingException result, long messageId,
487                long attachmentId, int progress) {
488        }
489
490        public void updateMailboxCallback(MessagingException result, long accountId,
491                long mailboxId, int progress, int numNewMessages) {
492            if (Config.LOGD && Email.DEBUG) {
493                Log.d(LOG_TAG, "updateMailboxCallback result=" + result
494                        + " accountId=" + accountId);
495            }
496            if (result == null) {
497                updateAccountReport(accountId, numNewMessages);
498                if (numNewMessages > 0) {
499                    notifyNewMessages(accountId);
500                }
501            } else {
502                updateAccountReport(accountId, -1);
503            }
504        }
505
506        public void updateMailboxListCallback(MessagingException result, long accountId,
507                int progress) {
508        }
509
510        public void serviceCheckMailCallback(MessagingException result, long accountId,
511                long mailboxId, int progress, long tag) {
512            if (Config.LOGD && Email.DEBUG) {
513                Log.d(LOG_TAG, "serviceCheckMailCallback result=" + result
514                        + " accountId=" + accountId + " progress=" + progress);
515            }
516            if (progress == 100) {
517                AlarmManager alarmManager = (AlarmManager)getSystemService(Context.ALARM_SERVICE);
518                reschedule(alarmManager);
519                int serviceId = MailService.this.mStartId;
520                if (tag != 0) {
521                    serviceId = (int) tag;
522                }
523                stopSelf(serviceId);
524            }
525        }
526
527        public void sendMailCallback(MessagingException result, long accountId, long messageId,
528                int progress) {
529        }
530    }
531
532    /**
533     * Prepare notifications for a given new account having received mail
534     * The notification is organized around the account that has the new mail (e.g. selecting
535     * the alert preferences) but the notification will include a summary if other
536     * accounts also have new mail.
537     */
538    private void notifyNewMessages(long accountId) {
539        boolean notify = false;
540        boolean vibrate = false;
541        Uri ringtone = null;
542        int accountsWithNewMessages = 0;
543        int numNewMessages = 0;
544        String reportName = null;
545        synchronized (mSyncReports) {
546            for (AccountSyncReport report : mSyncReports.values()) {
547                if (report.numNewMessages == 0) {
548                    continue;
549                }
550                numNewMessages += report.numNewMessages;
551                accountsWithNewMessages += 1;
552                if (report.accountId == accountId) {
553                    notify = report.notify;
554                    vibrate = report.vibrate;
555                    ringtone = report.ringtoneUri;
556                    reportName = report.displayName;
557                }
558            }
559        }
560        if (!notify) {
561            return;
562        }
563
564        // set up to post a notification
565        Intent intent;
566        String reportString;
567
568        if (accountsWithNewMessages == 1) {
569            // Prepare a report for a single account
570            // "12 unread (gmail)"
571            reportString = getResources().getQuantityString(
572                    R.plurals.notification_new_one_account_fmt, numNewMessages,
573                    numNewMessages, reportName);
574            intent = MessageList.actionHandleAccountIntent(this,
575                    accountId, -1, Mailbox.TYPE_INBOX);
576        } else {
577            // Prepare a report for multiple accounts
578            // "4 accounts"
579            reportString = getResources().getQuantityString(
580                    R.plurals.notification_new_multi_account_fmt, accountsWithNewMessages,
581                    accountsWithNewMessages);
582            intent = MessageList.actionHandleAccountIntent(this,
583                    -1, Mailbox.QUERY_ALL_INBOXES, -1);
584        }
585
586        // prepare appropriate pending intent, set up notification, and send
587        PendingIntent pending = PendingIntent.getActivity(this, 0, intent, 0);
588
589        Notification notification = new Notification(
590                R.drawable.stat_notify_email_generic,
591                getString(R.string.notification_new_title),
592                System.currentTimeMillis());
593        notification.setLatestEventInfo(this,
594                getString(R.string.notification_new_title),
595                reportString,
596                pending);
597
598        notification.sound = ringtone;
599        // Use same code here as in Gmail and GTalk for vibration
600        if (vibrate) {
601            notification.defaults |= Notification.DEFAULT_VIBRATE;
602        }
603
604        // This code is identical to that used by Gmail and GTalk for notifications
605        notification.flags |= Notification.FLAG_SHOW_LIGHTS;
606        notification.ledARGB = 0xff00ff00;
607        notification.ledOnMS = 500;
608        notification.ledOffMS = 2000;
609
610        NotificationManager notificationManager =
611            (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
612        notificationManager.notify(NEW_MESSAGE_NOTIFICATION_ID, notification);
613    }
614}
615