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