diff options
Diffstat (limited to 'src/com/android/email/NotificationController.java')
| -rw-r--r-- | src/com/android/email/NotificationController.java | 493 |
1 files changed, 304 insertions, 189 deletions
diff --git a/src/com/android/email/NotificationController.java b/src/com/android/email/NotificationController.java index 08e1aa1f8..86b5d3d8f 100644 --- a/src/com/android/email/NotificationController.java +++ b/src/com/android/email/NotificationController.java @@ -20,13 +20,16 @@ import com.android.email.activity.ContactStatusLoader; import com.android.email.activity.Welcome; import com.android.email.activity.setup.AccountSecurity; import com.android.email.activity.setup.AccountSettingsXL; +import com.android.emailcommon.Logging; import com.android.emailcommon.mail.Address; import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.EmailContent.Account; -import com.android.emailcommon.provider.EmailContent.AccountColumns; import com.android.emailcommon.provider.EmailContent.Attachment; +import com.android.emailcommon.provider.EmailContent.Mailbox; +import com.android.emailcommon.provider.EmailContent.MailboxColumns; import com.android.emailcommon.provider.EmailContent.Message; -import com.android.emailcommon.utility.EmailAsyncTask; +import com.android.emailcommon.provider.EmailContent.MessageColumns; +import com.android.emailcommon.provider.ProviderUnavailableException; import com.android.emailcommon.utility.Utility; import com.google.common.annotations.VisibleForTesting; @@ -34,24 +37,23 @@ import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.ContentObserver; +import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.media.AudioManager; import android.net.Uri; import android.os.Handler; +import android.os.Looper; +import android.os.Process; import android.text.SpannableString; import android.text.TextUtils; import android.text.style.TextAppearanceSpan; +import android.util.Log; -import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; -import java.util.NoSuchElementException; /** * Class that manages notifications. @@ -68,16 +70,27 @@ public class NotificationController { private static final int NOTIFICATION_ID_BASE_NEW_MESSAGES = 0x10000000; private static final int NOTIFICATION_ID_BASE_LOGIN_WARNING = 0x20000000; + /** Selection to retrieve accounts that should we notify user for changes */ + private final static String NOTIFIED_ACCOUNT_SELECTION = + Account.FLAGS + "&" + Account.FLAGS_NOTIFY_NEW_MAIL + " != 0"; + /** special account ID for the new message notification APIs to specify "all accounts" */ + private static final long ALL_ACCOUNTS = -1L; + /** Index into notification table returned from system preferences */ + private static final int NOTIFIED_MESSAGE_ID_INDEX = 0; + /** Index into notification table returned from system preferences */ + private static final int NOTIFIED_MESSAGE_COUNT_INDEX = 1; + + private static NotificationThread sNewMessageThread; + private static Handler sNewMessageHandler; private static NotificationController sInstance; private final Context mContext; private final NotificationManager mNotificationManager; private final AudioManager mAudioManager; private final Bitmap mGenericSenderIcon; private final Clock mClock; - // TODO The service context used to create and manage the notification controller is NOT - // guaranteed to live forever. As such, we may lose the data in this structure. We should - // save / restore this data upon service termination / start. We'd also want to define - // the behaviour after a restart. + // TODO We're maintaining all of our structures based upon the account ID. This is fine + // for now since the assumption is that we only ever look for changes in an account's + // INBOX. We should adjust our logic to use the mailbox ID instead. /** Maps account id to the message data */ private final HashMap<Long, MessageData> mNotificationMap; @@ -166,15 +179,6 @@ public class NotificationController { } /** - * Cancels the specified notification. - * - * @param notificationId The ID of the notification to register with the service. - */ - private void cancelNotification(int notificationId) { - mNotificationManager.cancel(notificationId); - } - - /** * Returns a notification ID for new message notifications for the given account. */ private int getNewMessageNotificationId(long accountId) { @@ -183,100 +187,201 @@ public class NotificationController { } /** - * Cancels a "new message" notification for the specified account. - * - * @param accountId The ID of the account to cancel for. If {@code -1}, "new message" - * notifications for all accounts will be canceled. + * Tells the notification controller if it should be watching for changes to the message table. + * This is the main life cycle method for message notifications. When we stop observing + * database changes, we save the state [e.g. message ID and count] of the most recent + * notification shown to the user. And, when we start observing database changes, we restore + * the saved state. + * @param watch If {@code true}, we register observers for all accounts whose settings have + * notifications enabled. Otherwise, all observers are unregistered with the database. */ - public void cancelNewMessageNotification(final long accountId) { - if (accountId == -1) { - for (long id : mNotificationMap.keySet()) { - cancelNewMessageNotification(id); - } - } else { - MessageData data = mNotificationMap.remove(accountId); - if (data == null) { - // Not in map; nothing to do here - return; + public void watchForMessages(final boolean watch) { + // Don't create the thread if we're only going to stop watching + if (!watch && sNewMessageHandler == null) return; + + synchronized(sInstance) { + if (sNewMessageHandler == null) { + sNewMessageThread = new NotificationThread(); + sNewMessageHandler = new Handler(sNewMessageThread.getLooper()); } - // ensure we don't accidentally double-cancel a notification - final ContentObserver myObserver = data.mObserver; - data.mObserver = null; - mNotificationManager.cancel(getNewMessageNotificationId(accountId)); - - // now do the database work - EmailAsyncTask.runAsyncParallel(new Runnable() { - @Override - public void run() { - ContentResolver resolver = mContext.getContentResolver(); - if (myObserver != null) { - resolver.unregisterContentObserver(myObserver); + } + // Run this on the message notification handler + sNewMessageHandler.post(new Runnable() { + @Override + public void run() { + ContentResolver resolver = mContext.getContentResolver(); + HashMap<Long, long[]> table; + if (!watch) { + table = new HashMap<Long, long[]>(); + for (Long key : mNotificationMap.keySet()) { + MessageData data = mNotificationMap.get(key); + table.put(key, + new long[] { data.mNotifiedMessageId, data.mNotifiedMessageCount }); } - Uri uri = Account.RESET_NEW_MESSAGE_COUNT_URI; - uri = ContentUris.withAppendedId(uri, accountId); - resolver.update(uri, null, null, null); + Preferences.getPreferences(mContext).setMessageNotificationTable(table); + unregisterMessageNotification(ALL_ACCOUNTS); + // TODO cancel existing account observers + + // tear down the event loop + sNewMessageThread.quit(); + sNewMessageHandler = null; + sNewMessageThread = null; + return; } - }); - } + + // otherwise, start new observers for all notified accounts + registerMessageNotification(ALL_ACCOUNTS); + // Need to load preferences _after_ starting the notifications. Otherwise, the + // notification map will not be built. + table = Preferences.getPreferences(mContext).getMessageNotificationTable(); + for (Long key : table.keySet()) { + MessageData data = mNotificationMap.get(key); + if (data != null) { + long[] value = table.get(key); + + data.mNotifiedMessageId = value[NOTIFIED_MESSAGE_ID_INDEX]; + data.mNotifiedMessageCount = (int) value[NOTIFIED_MESSAGE_COUNT_INDEX]; + } + } + // Loop through the observers and fire them once + for (MessageData data : mNotificationMap.values()) { + if (data.mObserver != null) { + data.mObserver.onChange(true); + } + } + // TODO Add an account observer to track when an account is removed + // TODO Add an account observer to track when an account is added + } + }); } /** - * Show (or update) a "new message" notification for the given account. - * - * @param accountId The ID of the account to display a notification for. - * @param addedMessages A list of new message IDs added to the given account. + * Registers an observer for changes to the INBOX for the given account. Since accounts + * may only have a single INBOX, we will never have more than one observer for an account. + * NOTE: This must be called on the notification handler thread. + * @param accountId The ID of the account to register the observer for. May be + * {@link #ALL_ACCOUNTS} to register observers for all accounts that allow + * for user notification. */ - public void showNewMessageNotification(final long accountId, - final ArrayList<Long> addedMessages) { - if (addedMessages == null || addedMessages.size() == 0) { - // No messages added; nothing to do here - return; - } - MessageData data = mNotificationMap.get(accountId); - if (data == null) { + private void registerMessageNotification(long accountId) { + ContentResolver resolver = mContext.getContentResolver(); + if (accountId == ALL_ACCOUNTS) { + Cursor c = resolver.query( + Account.CONTENT_URI, EmailContent.ID_PROJECTION, + NOTIFIED_ACCOUNT_SELECTION, null, null); + try { + while (c.moveToNext()) { + long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN); + registerMessageNotification(id); + } + } finally { + c.close(); + } + } else { + MessageData data = mNotificationMap.get(accountId); + if (data != null) return; // we're already observing; nothing to do + data = new MessageData(); + Mailbox mailbox = Mailbox.restoreMailboxOfType(mContext, accountId, Mailbox.TYPE_INBOX); + ContentObserver observer = new MessageContentObserver( + sNewMessageHandler, mContext, mailbox.mId, accountId); + resolver.registerContentObserver(Message.NOTIFIER_URI, true, observer); + data.mObserver = observer; mNotificationMap.put(accountId, data); } - final HashSet<Long> idSet = data.mMessageList; - synchronized (idSet) { - idSet.addAll(addedMessages); - } - // Pick a message to observe - final long messageId = idSet.iterator().next(); - final ContentObserver myObserver; - if (data.mObserver == null) { - myObserver = new MessageContentObserver(Utility.getMainThreadHandler(), mContext, - accountId, messageId); - data.mObserver = myObserver; + } + + /** + * Unregisters the observer for the given account. If the specified account does not have + * a registered observer, no action is performed. + * NOTE: This must be called on the notification handler thread. + * @param accountId The ID of the account to unregister from. May be {@link #ALL_ACCOUNTS} to + * unregister all accounts that have observers. + */ + private void unregisterMessageNotification(long accountId) { + ContentResolver resolver = mContext.getContentResolver(); + if (accountId == ALL_ACCOUNTS) { + // cancel all existing message observers + for (MessageData data : mNotificationMap.values()) { + ContentObserver observer = data.mObserver; + resolver.unregisterContentObserver(observer); + } + mNotificationMap.clear(); } else { - myObserver = data.mObserver; + MessageData data = mNotificationMap.remove(accountId); + if (data != null) { + ContentObserver observer = data.mObserver; + resolver.unregisterContentObserver(observer); + } + } + } + + /** + * Cancels a "new message" notification for the specified account. + * + * @param accountId The ID of the account to cancel for. If {@link #ALL_ACCOUNTS}, "new message" + * notifications for all accounts will be canceled. + * @deprecated Components should not be invoking the notification controller directly. + */ + @Deprecated + public void cancelNewMessageNotification(final long accountId) { + synchronized(sInstance) { + // If we don't have a handler, we'll figure out the correct accounts to + // notify the next time we start the controller. + if (sNewMessageHandler == null) return; } - EmailAsyncTask.runAsyncParallel(new Runnable() { + // Run this on the message notification handler + sNewMessageHandler.post(new Runnable() { + private void clearNotification(long accountId) { + if (accountId == ALL_ACCOUNTS) { + for (long id : mNotificationMap.keySet()) { + clearNotification(id); + } + } else { + unregisterMessageNotification(accountId); + mNotificationManager.cancel(getNewMessageNotificationId(accountId)); + } + } @Override public void run() { - ContentResolver resolver = mContext.getContentResolver(); - // Atomically update the unseen count - ContentValues cv = new ContentValues(); - cv.put(EmailContent.FIELD_COLUMN_NAME, AccountColumns.NEW_MESSAGE_COUNT); - cv.put(EmailContent.ADD_COLUMN_NAME, addedMessages.size()); - Uri uri = ContentUris.withAppendedId(Account.ADD_TO_FIELD_URI, accountId); - resolver.update(uri, cv, null, null); - // Get the unseen count - uri = ContentUris.withAppendedId(Account.CONTENT_URI, accountId); - int unseenMessageCount = Utility.getFirstRowInt(mContext, uri, - new String[] { AccountColumns.NEW_MESSAGE_COUNT }, null /*selection*/, - null /*selectionArgs*/, null /*sortOrder*/, 0 /*column*/, 0 /*default*/); - // Create the notification - Notification n = createNewMessageNotification(accountId, unseenMessageCount, true); - if (n == null) { - return; + clearNotification(accountId); + } + }); + } + + /** + * Reset the message notification for the given account to the most recent message ID. + * This is not complete and will still exhibit the existing (wrong) behaviour of notifying + * the user even if the Email UX is visible. + * NOTE: Only for short-term legacy support. To be replaced with a way to temporarily stop + * notifications for a mailbox. + * @deprecated + */ + @Deprecated + public void resetMessageNotification(final long accountId) { + synchronized(sInstance) { + if (sNewMessageHandler == null) { + sNewMessageThread = new NotificationThread(); + sNewMessageHandler = new Handler(sNewMessageThread.getLooper()); + } + } + + // Run this on the message notification handler + sNewMessageHandler.post(new Runnable() { + private void resetNotification(long accountId) { + if (accountId == ALL_ACCOUNTS) { + for (long id : mNotificationMap.keySet()) { + resetNotification(id); + } + } else { + Utility.updateLastSeenMessageKey(mContext, accountId); + mNotificationManager.cancel(getNewMessageNotificationId(accountId)); } - // Register a content observer with one of the messages - uri = ContentUris.withAppendedId(EmailContent.Message.CONTENT_URI, messageId); - resolver.registerContentObserver(uri, false, myObserver); - // Make the notification visible - mNotificationManager.notify(getNewMessageNotificationId(accountId), n); + } + @Override + public void run() { + resetNotification(accountId); } }); } @@ -305,14 +410,14 @@ public class NotificationController { * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) */ @VisibleForTesting - Notification createNewMessageNotification(long accountId, int unseenMessageCount, - boolean enableAudio) { + Notification createNewMessageNotification(long accountId, long messageId, + int unseenMessageCount, boolean enableAudio) { final Account account = Account.restoreAccountWithId(mContext, accountId); if (account == null) { return null; } // Get the latest message - final Message message = Message.getLatestIncomingMessage(mContext, accountId); + final Message message = Message.restoreMessageWithId(mContext, messageId); if (message == null) { return null; // no message found??? } @@ -476,8 +581,8 @@ public class NotificationController { * Cancels any password expire notifications [both expired & expiring]. */ public void cancelPasswordExpirationNotifications() { - cancelNotification(NOTIFICATION_ID_PASSWORD_EXPIRING); - cancelNotification(NOTIFICATION_ID_PASSWORD_EXPIRED); + mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING); + mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED); } /** @@ -498,121 +603,131 @@ public class NotificationController { * Cancels the security needed notification. */ public void cancelSecurityNeededNotification() { - cancelNotification(NOTIFICATION_ID_SECURITY_NEEDED); + mNotificationManager.cancel(NOTIFICATION_ID_SECURITY_NEEDED); } /** * Observer invoked whenever a message we're notifying the user about changes. */ private static class MessageContentObserver extends ContentObserver { - /** The account this observer is attached to */ - private final long mAccountId; - /** A singular message ID to notify on */ - private final long mMessageId; - /** The context */ + /** A selection to get messages the user hasn't seen before */ + private final static String MESSAGE_SELECTION = + MessageColumns.MAILBOX_KEY + "=? AND " + MessageColumns.ID + ">? AND " + + MessageColumns.FLAG_READ + "=0"; private final Context mContext; - /** The handler we will be invoked on */ - private final Handler mHandler; + private final long mMailboxId; + private final long mAccountId; - MessageContentObserver(Handler handler, Context context, long accountId, - long messageId) { - super (handler); - mHandler = handler; + public MessageContentObserver( + Handler handler, Context context, long mailboxId, long accountId) { + super(handler); mContext = context; + mMailboxId = mailboxId; mAccountId = accountId; - mMessageId = messageId; } @Override public void onChange(boolean selfChange) { super.onChange(selfChange); - final MessageData data = sInstance.mNotificationMap.get(mAccountId); - // If this account had been removed from the set of notifications or if the observer - // has been updated, make sure we don't get called again - if (data == null || data.mObserver != this) { - mContext.getContentResolver().unregisterContentObserver(this); - return; - } - // Ensure we're only handling one change at a time - EmailAsyncTask.runAsyncSerial(new Runnable() { - @Override - public void run() { - handleChange(data); - } - }); - } - - /** - * Performs any database operations to handle an observed change. - * - * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) - * @param data Message data for the observed account - */ - private void handleChange(MessageData data) { - Message message = Message.restoreMessageWithId(mContext, mMessageId); - if (message != null && !message.mFlagRead) { - // do nothing; wait until this message is modified + MessageData data = sInstance.mNotificationMap.get(mAccountId); + if (data == null) { + // notification for a mailbox that we aren't observing; this should not happen + Log.e(Logging.LOG_TAG, "Received notifiaction when observer data was null"); return; } + long oldMessageId = data.mNotifiedMessageId; + int oldMessageCount = data.mNotifiedMessageCount; - // message removed or read; get another one in the list and update the notification - // Remove ourselves from the set of notifiers ContentResolver resolver = mContext.getContentResolver(); - resolver.unregisterContentObserver(this); - synchronized (data.mMessageList) { - data.mMessageList.remove(mMessageId); - } + long lastSeenMessageId = Utility.getFirstRowLong( + mContext, Mailbox.CONTENT_URI, + new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY }, + EmailContent.ID_SELECTION, + new String[] { Long.toString(mMailboxId) }, null, 0, 0L); + Cursor c = resolver.query( + Message.CONTENT_URI, EmailContent.ID_PROJECTION, + MESSAGE_SELECTION, + new String[] { Long.toString(mMailboxId), Long.toString(lastSeenMessageId) }, + MessageColumns.ID + " DESC"); + if (c == null) throw new ProviderUnavailableException(); try { - for (;;) { - long nextMessageId = data.mMessageList.iterator().next(); - Message nextMessage = Message.restoreMessageWithId(mContext, nextMessageId); - if ((nextMessage == null) || (nextMessage.mFlagRead)) { - synchronized (data.mMessageList) { - data.mMessageList.remove(nextMessageId); - } - continue; - } - data.mObserver = new MessageContentObserver(mHandler, mContext, mAccountId, - nextMessageId); - Uri uri = ContentUris.withAppendedId( - EmailContent.Message.CONTENT_URI, nextMessageId); - resolver.registerContentObserver(uri, false, data.mObserver); - - // Update the new message count - int unseenMessageCount = data.mMessageList.size(); - ContentValues cv = new ContentValues(); - - cv.put(EmailContent.SET_COLUMN_NAME, unseenMessageCount); - uri = ContentUris.withAppendedId( - Account.RESET_NEW_MESSAGE_COUNT_URI, mAccountId); - resolver.update(uri, cv, null, null); - - // Re-display the notification w/o audio - Notification n = sInstance.createNewMessageNotification(mAccountId, - unseenMessageCount, false); - sInstance.mNotificationManager.notify( - sInstance.getNewMessageNotificationId(mAccountId), n); - break; + int newMessageCount = c.getCount(); + long newMessageId = 0L; + if (c.moveToNext()) { + newMessageId = c.getLong(EmailContent.ID_PROJECTION_COLUMN); } - } catch (NoSuchElementException e) { - // this is not an error; it means the list is empty, so, hide the notification - mHandler.post(new Runnable() { - @Override - public void run() { - // make sure we're on the UI thread to cancel the notification - sInstance.cancelNewMessageNotification(mAccountId); + + if (newMessageCount == 0) { + // No messages to notify for; clear the notification + sInstance.mNotificationManager.cancel( + sInstance.getNewMessageNotificationId(mAccountId)); + } else if (newMessageCount != oldMessageCount + || (newMessageId != 0 && newMessageId != oldMessageId)) { + // Either the count or last message has changed; update the notification + boolean playAudio = (oldMessageCount == 0); // play audio on first notification + Notification n = sInstance.createNewMessageNotification( + mAccountId, newMessageId, newMessageCount, playAudio); + if (n != null) { + // Make the notification visible + sInstance.mNotificationManager.notify( + sInstance.getNewMessageNotificationId(mAccountId), n); } - }); + } + data.mNotifiedMessageId = newMessageId; + data.mNotifiedMessageCount = newMessageCount; + } finally { + c.close(); } } } - /** - * Information about the message(s) we're notifying the user about. - */ + /** Information about the message(s) we're notifying the user about. */ private static class MessageData { - final HashSet<Long> mMessageList = new HashSet<Long>(); + /** The database observer */ ContentObserver mObserver; + /** Message ID used in the user notification */ + long mNotifiedMessageId; + /** Message count used in the user notification */ + int mNotifiedMessageCount; + } + + /** + * Thread to handle all notification actions through its own {@link Looper}. + */ + private static class NotificationThread implements Runnable { + /** Lock to ensure proper initialization */ + private final Object mLock = new Object(); + /** The {@link Looper} that handles messages for this thread */ + private Looper mLooper; + + NotificationThread() { + new Thread(null, this, "EmailNotification").start(); + synchronized (mLock) { + while (mLooper == null) { + try { + mLock.wait(); + } catch (InterruptedException ex) { + } + } + } + } + + @Override + public void run() { + synchronized (mLock) { + Looper.prepare(); + mLooper = Looper.myLooper(); + mLock.notifyAll(); + } + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + Looper.loop(); + } + void quit() { + mLooper.quit(); + } + Looper getLooper() { + return mLooper; + } } } |
