From 08ace26ed605946d788ce56f5c9aefc65131a63b Mon Sep 17 00:00:00 2001 From: Jorge Ruesga Date: Fri, 1 May 2015 21:35:23 +0200 Subject: [PATCH] email: imap push Change-Id: I8a184a5644e4322ee65d969e14cd47fe119f5df2 Signed-off-by: Jorge Ruesga --- .../android/emailcommon/provider/Account.java | 52 +- .../emailcommon/provider/EmailContent.java | 8 + .../android/emailcommon/provider/Mailbox.java | 3 + .../service/EmailServiceProxy.java | 6 + .../email/EmailConnectivityManager.java | 7 + .../activity/setup/AccountSettingsUtils.java | 2 +- .../email/mail/store/ImapConnection.java | 85 +- .../android/email/mail/store/ImapFolder.java | 377 +++++- .../android/email/mail/store/ImapStore.java | 9 + .../android/email/mail/store/Pop3Store.java | 4 + .../email/mail/store/imap/ImapConstants.java | 4 + .../email/mail/store/imap/ImapList.java | 2 +- .../email/mail/store/imap/ImapResponse.java | 7 + .../mail/store/imap/ImapResponseParser.java | 37 +- .../email/mail/transport/MailTransport.java | 8 + .../com/android/email/provider/DBHelper.java | 53 +- .../android/email/provider/EmailProvider.java | 71 +- .../com/android/email/provider/Utilities.java | 19 +- .../EmailBroadcastProcessorService.java | 4 + .../email/service/EmailServiceStub.java | 8 +- .../android/email/service/ImapService.java | 1132 ++++++++++++++++- .../service/LegacyImapSyncAdapterService.java | 136 +- .../service/PopImapSyncAdapterService.java | 46 +- res/xml/services.xml | 4 +- res/xml/syncadapter_legacy_imap.xml | 1 + .../setup/AccountCheckSettingsFragment.java | 4 + .../setup/AccountSettingsFragment.java | 40 +- .../activity/setup/AccountSetupFinal.java | 2 +- .../setup/AccountSetupOptionsFragment.java | 24 +- 29 files changed, 2097 insertions(+), 58 deletions(-) diff --git a/emailcommon/src/com/android/emailcommon/provider/Account.java b/emailcommon/src/com/android/emailcommon/provider/Account.java index 5a3ab7f3a..b9a21c805 100755 --- a/emailcommon/src/com/android/emailcommon/provider/Account.java +++ b/emailcommon/src/com/android/emailcommon/provider/Account.java @@ -31,6 +31,7 @@ import android.os.Parcel; import android.os.Parcelable; import android.os.RemoteException; +import com.android.emailcommon.service.EmailServiceProxy; import com.android.emailcommon.utility.Utility; import com.android.mail.utils.LogUtils; import com.google.common.annotations.VisibleForTesting; @@ -111,22 +112,26 @@ public final class Account extends EmailContent implements Parcelable { // Sentinel values for the mSyncInterval field of both Account records public static final int CHECK_INTERVAL_NEVER = -1; public static final int CHECK_INTERVAL_PUSH = -2; + public static final int CHECK_INTERVAL_DEFAULT_PULL = 15; public static Uri CONTENT_URI; public static Uri RESET_NEW_MESSAGE_COUNT_URI; public static Uri NOTIFIER_URI; + public static Uri SYNC_SETTING_CHANGED_URI; public static void initAccount() { CONTENT_URI = Uri.parse(EmailContent.CONTENT_URI + "/account"); RESET_NEW_MESSAGE_COUNT_URI = Uri.parse(EmailContent.CONTENT_URI + "/resetNewMessageCount"); NOTIFIER_URI = Uri.parse(EmailContent.CONTENT_NOTIFIER_URI + "/account"); + SYNC_SETTING_CHANGED_URI = Uri.parse( + EmailContent.CONTENT_SYNC_SETTING_CHANGED_URI + "/account"); } public String mDisplayName; public String mEmailAddress; public String mSyncKey; public int mSyncLookback; - public int mSyncInterval; + private int mSyncInterval; public long mHostAuthKeyRecv; public long mHostAuthKeySend; public int mFlags; @@ -139,6 +144,7 @@ public final class Account extends EmailContent implements Parcelable { public String mSignature; public long mPolicyKey; public long mPingDuration; + public int mCapabilities; @VisibleForTesting static final String JSON_TAG_HOST_AUTH_RECV = "hostAuthRecv"; @@ -171,6 +177,7 @@ public final class Account extends EmailContent implements Parcelable { public static final int CONTENT_POLICY_KEY_COLUMN = 14; public static final int CONTENT_PING_DURATION_COLUMN = 15; public static final int CONTENT_MAX_ATTACHMENT_SIZE_COLUMN = 16; + public static final int CONTENT_CAPABILITIES_COLUMN = 17; public static final String[] CONTENT_PROJECTION = { AttachmentColumns._ID, AccountColumns.DISPLAY_NAME, @@ -181,7 +188,7 @@ public final class Account extends EmailContent implements Parcelable { AccountColumns.RINGTONE_URI, AccountColumns.PROTOCOL_VERSION, AccountColumns.SECURITY_SYNC_KEY, AccountColumns.SIGNATURE, AccountColumns.POLICY_KEY, AccountColumns.PING_DURATION, - AccountColumns.MAX_ATTACHMENT_SIZE + AccountColumns.MAX_ATTACHMENT_SIZE, AccountColumns.CAPABILITIES }; public static final int ACCOUNT_FLAGS_COLUMN_ID = 0; @@ -279,6 +286,7 @@ public final class Account extends EmailContent implements Parcelable { mSignature = cursor.getString(CONTENT_SIGNATURE_COLUMN); mPolicyKey = cursor.getLong(CONTENT_POLICY_KEY_COLUMN); mPingDuration = cursor.getLong(CONTENT_PING_DURATION_COLUMN); + mCapabilities = cursor.getInt(CONTENT_CAPABILITIES_COLUMN); } public boolean isTemporary() { @@ -358,6 +366,11 @@ public final class Account extends EmailContent implements Parcelable { * TODO define sentinel values for "never", "push", etc. See Account.java */ public int getSyncInterval() { + // Fixed unsynced value and account capability. Change to default pull value + if (!hasCapability(EmailServiceProxy.CAPABILITY_PUSH) + && mSyncInterval == CHECK_INTERVAL_PUSH) { + return CHECK_INTERVAL_DEFAULT_PULL; + } return mSyncInterval; } @@ -367,7 +380,13 @@ public final class Account extends EmailContent implements Parcelable { * @param minutes the number of minutes between polling checks */ public void setSyncInterval(int minutes) { - mSyncInterval = minutes; + // Fixed unsynced value and account capability. Change to default pull value + if (!hasCapability(EmailServiceProxy.CAPABILITY_PUSH) + && mSyncInterval == CHECK_INTERVAL_PUSH) { + mSyncInterval = CHECK_INTERVAL_DEFAULT_PULL; + } else { + mSyncInterval = minutes; + } } /** @@ -402,6 +421,20 @@ public final class Account extends EmailContent implements Parcelable { mPingDuration = value; } + /** + * @return the current account capabilities. + */ + public int getCapabilities() { + return mCapabilities; + } + + /** + * Set the account capabilities. Be sure to call save() to commit to database. + */ + public void setCapabilities(int value) { + mCapabilities = value; + } + /** * @return the flags for this account */ @@ -749,6 +782,7 @@ public final class Account extends EmailContent implements Parcelable { values.put(AccountColumns.SIGNATURE, mSignature); values.put(AccountColumns.POLICY_KEY, mPolicyKey); values.put(AccountColumns.PING_DURATION, mPingDuration); + values.put(AccountColumns.CAPABILITIES, mCapabilities); return values; } @@ -779,6 +813,7 @@ public final class Account extends EmailContent implements Parcelable { json.putOpt(AccountColumns.PROTOCOL_VERSION, mProtocolVersion); json.putOpt(AccountColumns.SIGNATURE, mSignature); json.put(AccountColumns.PING_DURATION, mPingDuration); + json.put(AccountColumns.CAPABILITIES, mCapabilities); return json; } catch (final JSONException e) { LogUtils.d(LogUtils.TAG, e, "Exception while serializing Account"); @@ -817,6 +852,7 @@ public final class Account extends EmailContent implements Parcelable { a.mSignature = json.optString(AccountColumns.SIGNATURE); // POLICY_KEY is not stored a.mPingDuration = json.optInt(AccountColumns.PING_DURATION, 0); + a.mCapabilities = json.optInt(AccountColumns.CAPABILITIES, 0); return a; } catch (final JSONException e) { LogUtils.d(LogUtils.TAG, e, "Exception while deserializing Account"); @@ -842,6 +878,14 @@ public final class Account extends EmailContent implements Parcelable { } } + /** + * Returns whether or not the capability is supported by the account. + * @see EmailServiceProxy#CAPABILITY_* + */ + public boolean hasCapability(int capability) { + return (mCapabilities & capability) != 0; + } + /** * Supports Parcelable */ @@ -903,6 +947,7 @@ public final class Account extends EmailContent implements Parcelable { } else { dest.writeByte((byte)0); } + dest.writeInt(mCapabilities); } /** @@ -937,6 +982,7 @@ public final class Account extends EmailContent implements Parcelable { if (in.readByte() == 1) { mHostAuthSend = new HostAuth(in); } + mCapabilities = in.readInt(); } /** diff --git a/emailcommon/src/com/android/emailcommon/provider/EmailContent.java b/emailcommon/src/com/android/emailcommon/provider/EmailContent.java index f1fcb0dcf..dd4e7eb85 100755 --- a/emailcommon/src/com/android/emailcommon/provider/EmailContent.java +++ b/emailcommon/src/com/android/emailcommon/provider/EmailContent.java @@ -145,6 +145,8 @@ public abstract class EmailContent { // delete, or update) and is intended as an optimization for use by clients of message list // cursors (initially, the email AppWidget). public static String NOTIFIER_AUTHORITY; + // The sync settings changed authority is used to notify when a sync setting changed (interval) + public static String SYNC_SETTING_CHANGED_AUTHORITY; public static Uri CONTENT_URI; public static final String PARAMETER_LIMIT = "limit"; @@ -153,6 +155,7 @@ public abstract class EmailContent { */ public static final String SUPPRESS_COMBINED_ACCOUNT_PARAM = "suppress_combined"; public static Uri CONTENT_NOTIFIER_URI; + public static Uri CONTENT_SYNC_SETTING_CHANGED_URI; public static Uri PICK_TRASH_FOLDER_URI; public static Uri PICK_SENT_FOLDER_URI; public static Uri MAILBOX_NOTIFICATION_URI; @@ -175,8 +178,11 @@ public abstract class EmailContent { AUTHORITY = EMAIL_PACKAGE_NAME + ".provider"; LogUtils.d("EmailContent", "init for " + AUTHORITY); NOTIFIER_AUTHORITY = EMAIL_PACKAGE_NAME + ".notifier"; + SYNC_SETTING_CHANGED_AUTHORITY = EMAIL_PACKAGE_NAME + ".sync_setting_changed"; CONTENT_URI = Uri.parse("content://" + AUTHORITY); CONTENT_NOTIFIER_URI = Uri.parse("content://" + NOTIFIER_AUTHORITY); + CONTENT_SYNC_SETTING_CHANGED_URI = Uri.parse( + "content://" + SYNC_SETTING_CHANGED_AUTHORITY); PICK_TRASH_FOLDER_URI = Uri.parse("content://" + AUTHORITY + "/pickTrashFolder"); PICK_SENT_FOLDER_URI = Uri.parse("content://" + AUTHORITY + "/pickSentFolder"); MAILBOX_NOTIFICATION_URI = Uri.parse("content://" + AUTHORITY + "/mailboxNotification"); @@ -1724,6 +1730,8 @@ public abstract class EmailContent { public static final String PING_DURATION = "pingDuration"; // Automatically fetch pop3 attachments public static final String AUTO_FETCH_ATTACHMENTS = "autoFetchAttachments"; + // Account capabilities (check EmailServiceProxy#CAPABILITY_*) + public static final String CAPABILITIES = "capabilities"; } public interface QuickResponseColumns extends BaseColumns { diff --git a/emailcommon/src/com/android/emailcommon/provider/Mailbox.java b/emailcommon/src/com/android/emailcommon/provider/Mailbox.java index c726b94c3..75f840e64 100644 --- a/emailcommon/src/com/android/emailcommon/provider/Mailbox.java +++ b/emailcommon/src/com/android/emailcommon/provider/Mailbox.java @@ -78,10 +78,13 @@ public class Mailbox extends EmailContent implements EmailContent.MailboxColumns public static Uri CONTENT_URI; public static Uri MESSAGE_COUNT_URI; + public static Uri SYNC_SETTING_CHANGED_URI; public static void initMailbox() { CONTENT_URI = Uri.parse(EmailContent.CONTENT_URI + "/mailbox"); MESSAGE_COUNT_URI = Uri.parse(EmailContent.CONTENT_URI + "/mailboxCount"); + SYNC_SETTING_CHANGED_URI = Uri.parse( + EmailContent.CONTENT_SYNC_SETTING_CHANGED_URI + "/mailbox"); } private static String formatMailboxIdExtra(final int index) { diff --git a/emailcommon/src/com/android/emailcommon/service/EmailServiceProxy.java b/emailcommon/src/com/android/emailcommon/service/EmailServiceProxy.java index 1bbec7867..0c4c8e2a3 100644 --- a/emailcommon/src/com/android/emailcommon/service/EmailServiceProxy.java +++ b/emailcommon/src/com/android/emailcommon/service/EmailServiceProxy.java @@ -68,6 +68,12 @@ public class EmailServiceProxy extends ServiceProxy implements IEmailService { public static final String VALIDATE_BUNDLE_PROTOCOL_VERSION = "validate_protocol_version"; public static final String VALIDATE_BUNDLE_REDIRECT_ADDRESS = "validate_redirect_address"; + // Service capabilities + public static final String SETTINGS_BUNDLE_CAPABILITIES = "settings_capabilities"; + + // List of common interesting services capabilities + public static final int CAPABILITY_PUSH = 1 << 0; + private Object mReturn = null; private IEmailService mService; private final boolean isRemote; diff --git a/provider_src/com/android/email/EmailConnectivityManager.java b/provider_src/com/android/email/EmailConnectivityManager.java index 90a511f06..be930c910 100644 --- a/provider_src/com/android/email/EmailConnectivityManager.java +++ b/provider_src/com/android/email/EmailConnectivityManager.java @@ -165,6 +165,13 @@ public class EmailConnectivityManager extends BroadcastReceiver { return info.getType(); } + static public boolean isConnected(Context context) { + ConnectivityManager cm = + (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo info = cm.getActiveNetworkInfo(); + return info != null && info.isConnected(); + } + public void waitForConnectivity() { // If we're unregistered, throw an exception if (!mRegistered) { diff --git a/provider_src/com/android/email/activity/setup/AccountSettingsUtils.java b/provider_src/com/android/email/activity/setup/AccountSettingsUtils.java index dbbd51ee7..cf677ddc0 100644 --- a/provider_src/com/android/email/activity/setup/AccountSettingsUtils.java +++ b/provider_src/com/android/email/activity/setup/AccountSettingsUtils.java @@ -107,7 +107,7 @@ public class AccountSettingsUtils { cv.put(AccountColumns.DISPLAY_NAME, account.getDisplayName()); cv.put(AccountColumns.SENDER_NAME, account.getSenderName()); cv.put(AccountColumns.SIGNATURE, account.getSignature()); - cv.put(AccountColumns.SYNC_INTERVAL, account.mSyncInterval); + cv.put(AccountColumns.SYNC_INTERVAL, account.getSyncInterval()); cv.put(AccountColumns.FLAGS, account.mFlags); cv.put(AccountColumns.SYNC_LOOKBACK, account.mSyncLookback); cv.put(AccountColumns.SECURITY_SYNC_KEY, account.mSecuritySyncKey); diff --git a/provider_src/com/android/email/mail/store/ImapConnection.java b/provider_src/com/android/email/mail/store/ImapConnection.java index bf4bb2a4c..bef5f346e 100644 --- a/provider_src/com/android/email/mail/store/ImapConnection.java +++ b/provider_src/com/android/email/mail/store/ImapConnection.java @@ -36,6 +36,7 @@ import com.android.emailcommon.mail.MessagingException; import com.android.mail.utils.LogUtils; import java.io.IOException; +import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -50,6 +51,15 @@ class ImapConnection { // Always check in FALSE private static final boolean DEBUG_FORCE_SEND_ID = false; + // RFC 2177 defines that IDLE connections must be refreshed at least every 29 minutes + public static final int PING_IDLE_TIMEOUT = 29 * 60 * 1000; + + // Special timeout for DONE operations + public static final int DONE_TIMEOUT = 5 * 1000; + + // Time to wait between the first idle message and triggering the changes + private static final int IDLE_OP_READ_TIMEOUT = 500; + /** ID capability per RFC 2971*/ public static final int CAPABILITY_ID = 1 << 0; /** NAMESPACE capability per RFC 2342 */ @@ -58,6 +68,8 @@ class ImapConnection { public static final int CAPABILITY_STARTTLS = 1 << 2; /** UIDPLUS capability per RFC 4315 */ public static final int CAPABILITY_UIDPLUS = 1 << 3; + /** IDLE capability per RFC 2177 */ + public static final int CAPABILITY_IDLE = 1 << 4; /** The capabilities supported; a set of CAPABILITY_* values. */ private int mCapabilities; @@ -69,6 +81,8 @@ class ImapConnection { private String mAccessToken; private String mIdPhrase = null; + private boolean mIdling = false; + /** # of command/response lines to log upon crash. */ private static final int DISCOURSE_LOGGER_SIZE = 64; private final DiscourseLogger mDiscourse = new DiscourseLogger(DISCOURSE_LOGGER_SIZE); @@ -210,10 +224,23 @@ class ImapConnection { mImapStore = null; } + int getReadTimeout() throws IOException { + if (mTransport == null) { + return MailTransport.SOCKET_READ_TIMEOUT; + } + return mTransport.getReadTimeout(); + } + + void setReadTimeout(int timeout) throws IOException { + if (mTransport != null) { + mTransport.setReadTimeout(timeout); + } + } + /** * Returns whether or not the specified capability is supported by the server. */ - private boolean isCapable(int capability) { + public boolean isCapable(int capability) { return (mCapabilities & capability) != 0; } @@ -235,6 +262,9 @@ class ImapConnection { if (capabilities.contains(ImapConstants.STARTTLS)) { mCapabilities |= CAPABILITY_STARTTLS; } + if (capabilities.contains(ImapConstants.IDLE)) { + mCapabilities |= CAPABILITY_IDLE; + } } /** @@ -273,6 +303,12 @@ class ImapConnection { */ String sendCommand(String command, boolean sensitive) throws MessagingException, IOException { + // Don't allow any command other than DONE when idling + if (mIdling && !command.equals(ImapConstants.DONE)) { + return null; + } + mIdling = command.equals(ImapConstants.IDLE); + LogUtils.d(Logging.LOG_TAG, "sendCommand %s", (sensitive ? IMAP_REDACTED_LOG : command)); open(); return sendCommandInternal(command, sensitive); @@ -284,7 +320,13 @@ class ImapConnection { throw new IOException("Null transport"); } String tag = Integer.toString(mNextCommandTag.incrementAndGet()); - String commandToSend = tag + " " + command; + final String commandToSend; + if (command.equals(ImapConstants.DONE)) { + // Do not send a tag for DONE command + commandToSend = command; + } else { + commandToSend = tag + " " + command; + } mTransport.writeLine(commandToSend, sensitive ? IMAP_REDACTED_LOG : null); mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend); return tag; @@ -327,6 +369,11 @@ class ImapConnection { return executeSimpleCommand(command, false); } + List executeIdleCommand() throws IOException, MessagingException { + mParser.expectIdlingResponse(); + return executeSimpleCommand(ImapConstants.IDLE, false); + } + /** * Read and return all of the responses from the most recent command sent to the server * @@ -336,13 +383,35 @@ class ImapConnection { */ List getCommandResponses() throws IOException, MessagingException { final List responses = new ArrayList(); - ImapResponse response; - do { - response = mParser.readResponse(); - responses.add(response); - } while (!response.isTagged()); + ImapResponse response = null; + boolean idling = false; + boolean throwSocketTimeoutEx = true; + int lastSocketTimeout = getReadTimeout(); + try { + do { + response = mParser.readResponse(); + if (idling) { + setReadTimeout(IDLE_OP_READ_TIMEOUT); + throwSocketTimeoutEx = false; + } + responses.add(response); + if (response.isIdling()) { + idling = true; + } + } while (idling || !response.isTagged()); + } catch (SocketTimeoutException ex) { + if (throwSocketTimeoutEx) { + throw ex; + } + } finally { + mParser.resetIdlingStatus(); + if (lastSocketTimeout != getReadTimeout()) { + setReadTimeout(lastSocketTimeout); + } + } - if (!response.isOk()) { + // When idling, any response is valid; otherwise it must be OK + if (!response.isOk() && !idling) { final String toString = response.toString(); final String status = response.getStatusOrEmpty().getString(); final String alert = response.getAlertTextOrEmpty().getString(); diff --git a/provider_src/com/android/email/mail/store/ImapFolder.java b/provider_src/com/android/email/mail/store/ImapFolder.java index 2eefdfec3..eb0535d2a 100644 --- a/provider_src/com/android/email/mail/store/ImapFolder.java +++ b/provider_src/com/android/email/mail/store/ImapFolder.java @@ -52,6 +52,8 @@ import com.android.emailcommon.utility.Utility; import com.android.mail.utils.LogUtils; import com.google.common.annotations.VisibleForTesting; +import static com.android.emailcommon.Logging.LOG_TAG; + import org.apache.commons.io.IOUtils; import java.io.File; @@ -60,6 +62,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.SocketTimeoutException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -68,13 +71,39 @@ import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.TimeZone; -class ImapFolder extends Folder { +public class ImapFolder extends Folder { private final static Flag[] PERMANENT_FLAGS = { Flag.DELETED, Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED }; private static final int COPY_BUFFER_SIZE = 16*1024; + public interface IdleCallback { + /** + * Invoked when the connection enters idle mode + */ + public void onIdled(); + /** + * Invoked when a new change is communicated by the server. + * + * @param needSync whether a sync is required + * @param fetchMessages list of message UIDs to update + */ + public void onNewServerChange(boolean needSync, List fetchMessages); + /** + * Connection to socket timed out. The idle connection needs + * to be considered broken when this is called. + */ + public void onTimeout(); + /** + * Something went wrong while waiting for push data. + * + * @param ex the exception detected + */ + public void onException(MessagingException ex); + } + private final ImapStore mStore; private final String mName; private int mMessageCount = -1; @@ -86,6 +115,22 @@ class ImapFolder extends Folder { /** A set of hashes that can be used to track dirtiness */ Object mHash[]; + private final Object mIdleSync = new Object(); + private boolean mIdling; + private boolean mIdlingCancelled; + private boolean mDiscardIdlingConnection; + private Thread mIdleReader; + + private static final String[] IDLE_STATUSES = { + ImapConstants.UIDVALIDITY, ImapConstants.UIDNEXT + }; + private Map mIdleStatuses = new HashMap<>(); + + private static class ImapIdleChanges { + public boolean mRequiredSync = false; + public ArrayList mMessageToFetch = new ArrayList<>(); + } + /*package*/ ImapFolder(ImapStore store, String name) { mStore = store; mName = name; @@ -176,6 +221,159 @@ class ImapFolder extends Folder { return mName; } + public void startIdling(final IdleCallback callback) throws MessagingException { + checkOpen(); + synchronized (mIdleSync) { + if (mIdling) { + throw new MessagingException("Folder " + mName + " is in IDLE state already."); + } + mIdling = true; + mIdlingCancelled = false; + mDiscardIdlingConnection = false; + } + + // Run idle in background + mIdleReader = new Thread() { + @Override + public void run() { + try { + // Get some info before start idling + mIdleStatuses = getStatuses(IDLE_STATUSES); + + // We setup the max time specified in RFC 2177 to re-issue + // an idle request to the server + mConnection.setReadTimeout(ImapConnection.PING_IDLE_TIMEOUT); + mConnection.destroyResponses(); + + // Enter now in idle status (we hold a connection with + // the server to listen for new changes) + synchronized (mIdleSync) { + if (mIdlingCancelled) { + return; + } + } + + if (callback != null) { + callback.onIdled(); + } + List responses = mConnection.executeIdleCommand(); + + // Check whether IDLE was successful (first response is an idling response) + if (responses.isEmpty() || (mIdling && !responses.get(0).isIdling())) { + if (callback != null) { + callback.onException(new MessagingException( + MessagingException.SERVER_ERROR, "Cannot idle")); + } + synchronized (mIdleSync) { + mIdling = false; + } + return; + } + + // Exit idle if we are still in that state + boolean cancelled = false; + boolean discardConnection = false; + synchronized (mIdleSync) { + if (!mIdlingCancelled) { + try { + mConnection.setReadTimeout(ImapConnection.DONE_TIMEOUT); + mConnection.executeSimpleCommand(ImapConstants.DONE); + } catch (MessagingException me) { + // Ignore this exception caused by messages in the queue + } + } + + cancelled = mIdlingCancelled; + discardConnection = mDiscardIdlingConnection; + } + + if (!cancelled && callback != null) { + // Notify that new changes exists in the server. Remove + // the idling status response since is only relevant for the protocol + // We have to enter in idle + ImapIdleChanges changes = extractImapChanges( + new ArrayList(responses.subList(1, responses.size()))); + callback.onNewServerChange(changes.mRequiredSync, changes.mMessageToFetch); + } + + if (discardConnection) { + // Return the connection to the pool + close(false); + } + + synchronized (mIdleSync) { + mIdling = false; + } + + } catch (MessagingException me) { + close(false); + synchronized (mIdleSync) { + mIdling = false; + } + if (callback != null) { + callback.onException(me); + } + + } catch (SocketTimeoutException ste) { + close(false); + synchronized (mIdleSync) { + mIdling = false; + } + if (callback != null) { + callback.onTimeout(); + } + + } catch (IOException ioe) { + close(false); + synchronized (mIdleSync) { + mIdling = false; + } + if (callback != null) { + callback.onException(ioExceptionHandler(mConnection, ioe)); + } + + } + } + }; + mIdleReader.setName("IdleReader " + mStore.getAccount().mId + ":" + mName); + mIdleReader.start(); + } + + public void stopIdling(boolean discardConnection) throws MessagingException { + if (!isOpen()) { + throw new MessagingException("Folder " + mName + " is not open."); + } + synchronized (mIdleSync) { + if (!mIdling) { + throw new MessagingException("Folder " + mName + " isn't in IDLE state."); + } + try { + mIdlingCancelled = true; + mDiscardIdlingConnection = discardConnection; + // We can read responses here because we can block the buffer. Read commands + // are always done by startListener method (blocking idle) + mConnection.sendCommand(ImapConstants.DONE, false); + + } catch (MessagingException me) { + // Treat IOERROR messaging exception as IOException + if (me.getExceptionType() == MessagingException.IOERROR) { + close(false); + throw me; + } + + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + + } + } + } + + public boolean isIdling() { + synchronized (mIdleSync) { + return mIdling; + } + } + @Override public boolean exists() throws MessagingException { if (mExists) { @@ -373,6 +571,58 @@ class ImapFolder extends Folder { } } + public Map getStatuses(String[] statuses) throws MessagingException { + checkOpen(); + Map allReturnStatuses = new HashMap<>(); + try { + String flags = TextUtils.join(" ", statuses); + final List responses = mConnection.executeSimpleCommand( + String.format(Locale.US, + ImapConstants.STATUS + " \"%s\" (%s)", + ImapStore.encodeFolderName(mName, mStore.mPathPrefix), flags)); + // S: * STATUS mboxname (MESSAGES 231 UIDNEXT 44292) + for (ImapResponse response : responses) { + if (response.isDataResponse(0, ImapConstants.STATUS)) { + ImapList list = response.getListOrEmpty(2); + int count = list.size(); + for (int i = 0; i < count; i += 2) { + String key = list.getStringOrEmpty(i).getString(); + String value = list.getStringOrEmpty(i + 1).getString(); + allReturnStatuses.put(key, value); + } + } + } + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + return allReturnStatuses; + } + + private List getNewMessagesFromUid(String uid) throws MessagingException { + checkOpen(); + List nextMSNs = new ArrayList<>(); + try { + final List responses = mConnection.executeSimpleCommand( + ImapConstants.SEARCH + " " + ImapConstants.UID + " " + uid + ":*"); + // S: * SEARCH 1 2 3 + for (ImapResponse response : responses) { + if (response.isDataResponse(0, ImapConstants.SEARCH)) { + int count = response.size(); + for (int i = 1; i < count; i++) { + nextMSNs.add(response.getStringOrEmpty(i).getString()); + } + } + } + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + return nextMSNs; + } + @Override public void delete(boolean recurse) { throw new Error("ImapStore.delete() not yet implemented"); @@ -1270,7 +1520,9 @@ class ImapFolder extends Folder { if (DebugUtils.DEBUG) { LogUtils.d(Logging.LOG_TAG, "IO Exception detected: ", ioe); } - connection.close(); + if (connection != null) { + connection.close(); + } if (connection == mConnection) { mConnection = null; // To prevent close() from returning the connection to the pool. close(false); @@ -1278,6 +1530,127 @@ class ImapFolder extends Folder { return new MessagingException(MessagingException.IOERROR, "IO Error", ioe); } + private ImapIdleChanges extractImapChanges(List changes) throws MessagingException { + // Process the changes and fill the idle changes structure. + // Basically we should look for the next commands in this method: + // + // OK DONE + // No more changes + // n EXISTS + // Indicates that the mailbox changed => ignore + // n EXPUNGE + // Indicates a message were completely deleted => a full sync is required + // n RECENT + // New messages waiting in the server => use UIDNEXT to search for the new messages. + // If isn't possible to retrieve the new UID messages, then a full sync is required + // n FETCH (UID X FLAGS (...)) + // a message has changed and requires to fetch only X message + // (something change on that item). If UID is not present, a conversion + // from MSN to UID is required + + final ImapIdleChanges imapIdleChanges = new ImapIdleChanges(); + + int count = changes.size(); + if (Logging.LOGD) { + for (int i = 0; i < count; i++) { + ImapResponse change = (ImapResponse) changes.get(i); + if (Logging.LOGD) { + LogUtils.d(Logging.LOG_TAG, "Received: " + change.toString()); + } + } + } + + // We can't ask to the server, because the responses will be destroyed. We need + // to compute and fetch any related after we have all the responses processed + boolean hasNewMessages = false; + List msns = new ArrayList<>(); + for (int i = 0; i < count; i++) { + ImapResponse change = (ImapResponse) changes.get(i); + if (change.isOk() || change.isNo() || change.isBad()) { + // No more processing. DONE included + break; + } + try { + ImapElement element = change.getElementOrNone(1); + if (element.equals(ImapElement.NONE)) { + continue; + } + if (!element.isString()) { + continue; + } + + ImapString op = (ImapString) element; + if (op.is(ImapConstants.DONE)) { + break; + } else if (op.is(ImapConstants.EXISTS)) { + continue; + } else if (op.is(ImapConstants.EXPUNGE)) { + imapIdleChanges.mRequiredSync = true; + } else if (op.is(ImapConstants.RECENT)) { + hasNewMessages = true; + } else if (op.is(ImapConstants.FETCH) + && change.getElementOrNone(2).isList()) { + ImapList messageFlags = (ImapList) change.getElementOrNone(2); + String uid = ((ImapString) messageFlags.getKeyedStringOrEmpty( + ImapConstants.UID, true)).getString(); + if (!TextUtils.isEmpty(uid) && + !imapIdleChanges.mMessageToFetch.contains(uid)) { + imapIdleChanges.mMessageToFetch.add(uid); + } else { + msns.add(change.getStringOrEmpty(0).getString()); + } + } else { + if (Logging.LOGD) { + LogUtils.w(LOG_TAG, "Unrecognized imap change (" + change + + ") for mailbox " + mName); + } + } + + } catch (Exception ex) { + if (Logging.LOGD) { + LogUtils.e(LOG_TAG, "Failure processing imap change (" + change + + ") for mailbox " + mName, ex); + } + } + } + + // Check whether UIDVALIDITY changed - if yes, a full sync request is required + // NOTE: This needs to happen after parsing all responses; otherwise + // getStatuses will destroy the response + Map statuses = getStatuses(new String[] { ImapConstants.UIDVALIDITY }); + String oldUidValidity = mIdleStatuses.get(ImapConstants.UIDVALIDITY); + String newUidValidity = statuses.get(ImapConstants.UIDVALIDITY); + if (!TextUtils.equals(oldUidValidity, newUidValidity)) { + imapIdleChanges.mMessageToFetch.clear(); + imapIdleChanges.mRequiredSync = true; + return imapIdleChanges; + } + + // Recover the UIDs of new messages in case we don't do a full sync anyway + if (!imapIdleChanges.mRequiredSync) { + try { + // Retrieve new message UIDs + String uidNext = mIdleStatuses.get(ImapConstants.UIDNEXT); + if (hasNewMessages && !TextUtils.isEmpty(uidNext)) { + msns.addAll(getNewMessagesFromUid(uidNext)); + } + + // Transform MSNs to UIDs + for (String msn : msns) { + String[] uids = searchForUids(String.format(Locale.US, "%s:%s", msn, msn)); + imapIdleChanges.mMessageToFetch.add(uids[0]); + } + } catch (MessagingException ex) { + // Server doesn't support UID. We have to do a full sync (since + // we don't know what message changed) + imapIdleChanges.mMessageToFetch.clear(); + imapIdleChanges.mRequiredSync = true; + } + } + + return imapIdleChanges; + } + @Override public boolean equals(Object o) { if (o instanceof ImapFolder) { diff --git a/provider_src/com/android/email/mail/store/ImapStore.java b/provider_src/com/android/email/mail/store/ImapStore.java index 5fc83e001..ddabe1c4a 100644 --- a/provider_src/com/android/email/mail/store/ImapStore.java +++ b/provider_src/com/android/email/mail/store/ImapStore.java @@ -501,6 +501,14 @@ public class ImapStore extends Store { connection.destroyResponses(); } bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, result); + + // Shared capabilities (check EmailProxyServices for available shared capabilities) + int capabilities = 0; + if (connection.isCapable(ImapConnection.CAPABILITY_IDLE)) { + capabilities |= EmailServiceProxy.CAPABILITY_PUSH; + } + bundle.putInt(EmailServiceProxy.SETTINGS_BUNDLE_CAPABILITIES, capabilities); + return bundle; } @@ -556,6 +564,7 @@ public class ImapStore extends Store { while ((connection = mConnectionPool.poll()) != null) { try { connection.setStore(this); + connection.setReadTimeout(MailTransport.SOCKET_READ_TIMEOUT); connection.executeSimpleCommand(ImapConstants.NOOP); break; } catch (MessagingException e) { diff --git a/provider_src/com/android/email/mail/store/Pop3Store.java b/provider_src/com/android/email/mail/store/Pop3Store.java index 4ea75ccf3..b0aa9a2eb 100644 --- a/provider_src/com/android/email/mail/store/Pop3Store.java +++ b/provider_src/com/android/email/mail/store/Pop3Store.java @@ -186,6 +186,10 @@ public class Pop3Store extends Store { ioe.getMessage()); } bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, result); + + // No special capabilities + bundle.putInt(EmailServiceProxy.SETTINGS_BUNDLE_CAPABILITIES, 0); + return bundle; } diff --git a/provider_src/com/android/email/mail/store/imap/ImapConstants.java b/provider_src/com/android/email/mail/store/imap/ImapConstants.java index 9f4d59290..9c94fcf31 100644 --- a/provider_src/com/android/email/mail/store/imap/ImapConstants.java +++ b/provider_src/com/android/email/mail/store/imap/ImapConstants.java @@ -46,6 +46,7 @@ public final class ImapConstants { public static final String COPYUID = "COPYUID"; public static final String CREATE = "CREATE"; public static final String DELETE = "DELETE"; + public static final String DONE = "DONE"; public static final String EXAMINE = "EXAMINE"; public static final String EXISTS = "EXISTS"; public static final String EXPUNGE = "EXPUNGE"; @@ -58,6 +59,8 @@ public final class ImapConstants { public static final String FLAGS = "FLAGS"; public static final String FLAGS_SILENT = "FLAGS.SILENT"; public static final String ID = "ID"; + public static final String IDLE = "IDLE"; + public static final String IDLING = "idling"; public static final String INBOX = "INBOX"; public static final String INTERNALDATE = "INTERNALDATE"; public static final String LIST = "LIST"; @@ -73,6 +76,7 @@ public final class ImapConstants { public static final String PREAUTH = "PREAUTH"; public static final String READ_ONLY = "READ-ONLY"; public static final String READ_WRITE = "READ-WRITE"; + public static final String RECENT = "RECENT"; public static final String RENAME = "RENAME"; public static final String RFC822_SIZE = "RFC822.SIZE"; public static final String SEARCH = "SEARCH"; diff --git a/provider_src/com/android/email/mail/store/imap/ImapList.java b/provider_src/com/android/email/mail/store/imap/ImapList.java index e28355989..2ddf8227f 100644 --- a/provider_src/com/android/email/mail/store/imap/ImapList.java +++ b/provider_src/com/android/email/mail/store/imap/ImapList.java @@ -180,7 +180,7 @@ public class ImapList extends ImapElement { @Override public String toString() { - return mList.toString(); + return mList != null ? mList.toString() : "[null]"; } /** diff --git a/provider_src/com/android/email/mail/store/imap/ImapResponse.java b/provider_src/com/android/email/mail/store/imap/ImapResponse.java index 9f975f7bf..292ff92b2 100644 --- a/provider_src/com/android/email/mail/store/imap/ImapResponse.java +++ b/provider_src/com/android/email/mail/store/imap/ImapResponse.java @@ -76,6 +76,13 @@ public class ImapResponse extends ImapList { return is(0, ImapConstants.NO); } + /** + * @return whether it's an IDLE response. + */ + public boolean isIdling() { + return is(0, ImapConstants.IDLING); + } + /** * @return whether it's an {@code responseType} data response. (i.e. not tagged). * @param index where {@code responseType} should appear. e.g. 1 for "FETCH" diff --git a/provider_src/com/android/email/mail/store/imap/ImapResponseParser.java b/provider_src/com/android/email/mail/store/imap/ImapResponseParser.java index 8dd1cf610..5efea3109 100644 --- a/provider_src/com/android/email/mail/store/imap/ImapResponseParser.java +++ b/provider_src/com/android/email/mail/store/imap/ImapResponseParser.java @@ -66,6 +66,9 @@ public class ImapResponseParser { */ private final ArrayList mResponsesToDestroy = new ArrayList(); + private boolean mIdling; + private boolean mExpectIdlingResponse; + /** * Exception thrown when we receive BYE. It derives from IOException, so it'll be treated * in the same way EOF does. @@ -168,10 +171,17 @@ public class ImapResponseParser { } catch (RuntimeException e) { // Parser crash -- log network activities. onParseError(e); + mIdling = false; throw e; } catch (IOException e) { // Network error, or received an unexpected char. - onParseError(e); + // If we are idling don't parse the error, just let the upper layers + // handle the exception + if (!mIdling) { + onParseError(e); + } else { + mIdling = false; + } throw e; } @@ -242,6 +252,14 @@ public class ImapResponseParser { return ret; } + public void resetIdlingStatus() { + mIdling = false; + } + + public void expectIdlingResponse() { + mExpectIdlingResponse = true; + } + /** * Parse and return the response line. */ @@ -263,11 +281,26 @@ public class ImapResponseParser { responseToDestroy = new ImapResponse(null, true); // If it's continuation request, we don't really care what's in it. - responseToDestroy.add(new ImapSimpleString(readUntilEol())); + // NOTE: specs say the server is supposed to respond to the IDLE command + // with a continuation request response. To simplify internal handling, + // we'll always construct same response (ignoring the server text response). + // Our implementation always returns "+ idling". + if (mExpectIdlingResponse) { + // Discard the server message and put what we expected + readUntilEol(); + responseToDestroy.add(new ImapSimpleString(ImapConstants.IDLING)); + } else { + responseToDestroy.add(new ImapSimpleString(readUntilEol())); + } // Response has successfully been built. Let's return it. responseToReturn = responseToDestroy; responseToDestroy = null; + + mIdling = responseToReturn.isIdling(); + if (mIdling) { + mExpectIdlingResponse = true; + } } else { // Status response or response data final String tag; diff --git a/provider_src/com/android/email/mail/transport/MailTransport.java b/provider_src/com/android/email/mail/transport/MailTransport.java index 26801f93f..1767f19a7 100644 --- a/provider_src/com/android/email/mail/transport/MailTransport.java +++ b/provider_src/com/android/email/mail/transport/MailTransport.java @@ -191,6 +191,14 @@ public class MailTransport { } } + public int getReadTimeout() throws IOException { + return mSocket.getSoTimeout(); + } + + public void setReadTimeout(int timeout) throws IOException { + mSocket.setSoTimeout(timeout); + } + /** * Lightweight version of SSLCertificateSocketFactory.verifyHostname, which provides this * service but is not in the public API. diff --git a/provider_src/com/android/email/provider/DBHelper.java b/provider_src/com/android/email/provider/DBHelper.java index 18a4548fe..fa70e6f4f 100644 --- a/provider_src/com/android/email/provider/DBHelper.java +++ b/provider_src/com/android/email/provider/DBHelper.java @@ -58,6 +58,7 @@ import com.android.emailcommon.provider.MessageStateChange; import com.android.emailcommon.provider.Policy; import com.android.emailcommon.provider.QuickResponse; import com.android.emailcommon.provider.SuggestedContact; +import com.android.emailcommon.service.EmailServiceProxy; import com.android.emailcommon.service.LegacyPolicySet; import com.android.emailcommon.service.SyncWindow; import com.android.mail.providers.UIProvider; @@ -187,7 +188,8 @@ public final class DBHelper { // Version 127: Force mFlags to contain the correct flags for EAS accounts given a protocol // version above 12.0 // Version 129: Update all IMAP INBOX mailboxes to force synchronization - public static final int DATABASE_VERSION = 129; + // Version 130: Account capabilities (check EmailServiceProxy#CAPABILITY_*) + public static final int DATABASE_VERSION = 130; // Any changes to the database format *must* include update-in-place code. // Original version: 2 @@ -525,7 +527,8 @@ public final class DBHelper { + AccountColumns.POLICY_KEY + " integer, " + AccountColumns.MAX_ATTACHMENT_SIZE + " integer, " + AccountColumns.PING_DURATION + " integer, " - + AccountColumns.AUTO_FETCH_ATTACHMENTS + " integer" + + AccountColumns.AUTO_FETCH_ATTACHMENTS + " integer, " + + AccountColumns.CAPABILITIES + " integer default 0" + ");"; db.execSQL("create table " + Account.TABLE_NAME + s); // Deleting an account deletes associated Mailboxes and HostAuth's @@ -1562,6 +1565,52 @@ public final class DBHelper { + HostAuth.TABLE_NAME + "." + HostAuthColumns.PROTOCOL + "='imap'));"); } + if (oldVersion <= 130) { + //Account capabilities (check EmailServiceProxy#CAPABILITY_*) + try { + // Create capabilities field + db.execSQL("alter table " + Account.TABLE_NAME + + " add column " + AccountColumns.CAPABILITIES + + " integer" + " default 0;"); + + // Update all accounts with the appropriate capabilities + Cursor c = db.rawQuery("select " + Account.TABLE_NAME + "." + + AccountColumns._ID + ", " + HostAuth.TABLE_NAME + "." + + HostAuthColumns.PROTOCOL + " from " + Account.TABLE_NAME + ", " + + HostAuth.TABLE_NAME + " where " + Account.TABLE_NAME + "." + + AccountColumns.HOST_AUTH_KEY_RECV + " = " + HostAuth.TABLE_NAME + + "." + HostAuthColumns._ID + ";", null); + if (c != null) { + try { + while(c.moveToNext()) { + long id = c.getLong(c.getColumnIndexOrThrow(AccountColumns._ID)); + String protocol = c.getString(c.getColumnIndexOrThrow( + HostAuthColumns.PROTOCOL)); + + int capabilities = 0; + if (protocol.equals(LEGACY_SCHEME_IMAP) + || protocol.equals(LEGACY_SCHEME_EAS)) { + // Don't know yet if the imap server supports the IDLE + // capability, but since this is upgrading the account, + // just assume that all imap servers supports the push + // capability and let disable it by the IMAP service + capabilities |= EmailServiceProxy.CAPABILITY_PUSH; + } + final ContentValues cv = new ContentValues(1); + cv.put(AccountColumns.CAPABILITIES, capabilities); + db.update(Account.TABLE_NAME, cv, AccountColumns._ID + " = ?", + new String[]{String.valueOf(id)}); + } + } finally { + c.close(); + } + } + } catch (final SQLException e) { + // Shouldn't be needed unless we're debugging and interrupt the process + LogUtils.w(TAG, "Exception upgrading EmailProvider.db from v129 to v130", e); + } + } + // Due to a bug in commit 44a064e5f16ddaac25f2acfc03c118f65bc48aec, // AUTO_FETCH_ATTACHMENTS column could not be available in the Account table. // Since cm12 and up doesn't use this column, we are leave as is it. In case diff --git a/provider_src/com/android/email/provider/EmailProvider.java b/provider_src/com/android/email/provider/EmailProvider.java index 338c9fc4b..8e4e7b6a1 100644 --- a/provider_src/com/android/email/provider/EmailProvider.java +++ b/provider_src/com/android/email/provider/EmailProvider.java @@ -189,11 +189,11 @@ public class EmailProvider extends ContentProvider "vnd.android.cursor.item/email-attachment"; /** Appended to the notification URI for delete operations */ - private static final String NOTIFICATION_OP_DELETE = "delete"; + public static final String NOTIFICATION_OP_DELETE = "delete"; /** Appended to the notification URI for insert operations */ - private static final String NOTIFICATION_OP_INSERT = "insert"; + public static final String NOTIFICATION_OP_INSERT = "insert"; /** Appended to the notification URI for update operations */ - private static final String NOTIFICATION_OP_UPDATE = "update"; + public static final String NOTIFICATION_OP_UPDATE = "update"; /** The query string to trigger a folder refresh. */ protected static String QUERY_UIREFRESH = "uirefresh"; @@ -833,6 +833,7 @@ public class EmailProvider extends ContentProvider // Notify all notifier cursors sendNotifierChange(getBaseNotificationUri(match), NOTIFICATION_OP_DELETE, id); + sendSyncSettingChanged(getBaseSyncSettingChangedUri(match), NOTIFICATION_OP_DELETE, id); // Notify all email content cursors notifyUI(EmailContent.CONTENT_URI, null); @@ -1075,6 +1076,7 @@ public class EmailProvider extends ContentProvider // Notify all notifier cursors sendNotifierChange(getBaseNotificationUri(match), NOTIFICATION_OP_INSERT, id); + sendSyncSettingChanged(getBaseSyncSettingChangedUri(match), NOTIFICATION_OP_INSERT, id); // Notify all existing cursors. notifyUI(EmailContent.CONTENT_URI, null); @@ -1924,7 +1926,7 @@ public class EmailProvider extends ContentProvider private static final int INDEX_SYNC_KEY = 2; /** - * Restart push if we need it (currently only for Exchange accounts). + * Restart push if we need it. * @param context A {@link Context}. * @param db The {@link SQLiteDatabase}. * @param id The id of the thing we're looking for. @@ -1937,9 +1939,13 @@ public class EmailProvider extends ContentProvider try { if (c.moveToFirst()) { final String protocol = c.getString(INDEX_PROTOCOL); - // Only restart push for EAS accounts that have completed initial sync. - if (context.getString(R.string.protocol_eas).equals(protocol) && - !EmailContent.isInitialSyncKey(c.getString(INDEX_SYNC_KEY))) { + final String syncKey = c.getString(INDEX_SYNC_KEY); + final boolean supportsPush = + context.getString(R.string.protocol_eas).equals(protocol) || + context.getString(R.string.protocol_legacy_imap).equals(protocol); + + // Only restart push for EAS or IMAP accounts that have completed initial sync. + if (supportsPush && !EmailContent.isInitialSyncKey(syncKey)) { final String emailAddress = c.getString(INDEX_EMAIL_ADDRESS); final android.accounts.Account account = getAccountManagerAccount(context, emailAddress, protocol); @@ -2010,6 +2016,7 @@ public class EmailProvider extends ContentProvider final SQLiteDatabase db = getDatabase(context); final int table = match >> BASE_SHIFT; int result; + boolean syncSettingChanged = false; // We do NOT allow setting of unreadCount/messageCount via the provider // These columns are maintained via triggers @@ -2159,6 +2166,14 @@ public class EmailProvider extends ContentProvider } } else if (match == MESSAGE_ID) { db.execSQL(UPDATED_MESSAGE_DELETE + id); + } else if (match == MAILBOX_ID) { + if (values.containsKey(MailboxColumns.SYNC_INTERVAL)) { + syncSettingChanged = true; + } + } else if (match == ACCOUNT_ID) { + if (values.containsKey(AccountColumns.SYNC_INTERVAL)) { + syncSettingChanged = true; + } } result = db.update(tableName, values, whereWithId(id, selection), selectionArgs); @@ -2293,6 +2308,10 @@ public class EmailProvider extends ContentProvider TextUtils.isEmpty(values.getAsString(AttachmentColumns.LOCATION))) { LogUtils.w(TAG, new Throwable(), "attachment with blank location"); } + } else if (match == MAILBOX) { + if (values.containsKey(MailboxColumns.SYNC_INTERVAL)) { + syncSettingChanged = true; + } } result = db.update(tableName, values, selection, selectionArgs); break; @@ -2314,6 +2333,10 @@ public class EmailProvider extends ContentProvider // Notify all notifier cursors if some records where changed in the database if (result > 0) { sendNotifierChange(getBaseNotificationUri(match), NOTIFICATION_OP_UPDATE, id); + if (syncSettingChanged) { + sendSyncSettingChanged(getBaseSyncSettingChangedUri(match), + NOTIFICATION_OP_UPDATE, id); + } notifyUI(notificationUri, null); } return result; @@ -2544,6 +2567,21 @@ public class EmailProvider extends ContentProvider return baseUri; } + private static Uri getBaseSyncSettingChangedUri(int match) { + Uri baseUri = null; + switch (match) { + case ACCOUNT: + case ACCOUNT_ID: + baseUri = Account.SYNC_SETTING_CHANGED_URI; + break; + case MAILBOX: + case MAILBOX_ID: + baseUri = Mailbox.SYNC_SETTING_CHANGED_URI; + break; + } + return baseUri; + } + /** * Sends a change notification to any cursors observers of the given base URI. The final * notification URI is dynamically built to contain the specified information. It will be @@ -2582,6 +2620,25 @@ public class EmailProvider extends ContentProvider } } + private void sendSyncSettingChanged(Uri baseUri, String op, String id) { + if (baseUri == null) return; + + // Append the operation, if specified + if (op != null) { + baseUri = baseUri.buildUpon().appendEncodedPath(op).build(); + } + + long longId = 0L; + try { + longId = Long.valueOf(id); + } catch (NumberFormatException ignore) {} + if (longId > 0) { + notifyUI(baseUri, id); + } else { + notifyUI(baseUri, null); + } + } + private void sendMessageListDataChangedNotification() { final Context context = getContext(); final Intent intent = new Intent(ACTION_NOTIFY_MESSAGE_LIST_DATASET_CHANGED); diff --git a/provider_src/com/android/email/provider/Utilities.java b/provider_src/com/android/email/provider/Utilities.java index c3b7ec93a..e28c873c4 100644 --- a/provider_src/com/android/email/provider/Utilities.java +++ b/provider_src/com/android/email/provider/Utilities.java @@ -40,6 +40,7 @@ import com.android.emailcommon.utility.ConversionUtilities; import com.android.mail.utils.LogUtils; import com.android.mail.utils.Utils; +import java.io.InputStream; import java.io.IOException; import java.util.ArrayList; @@ -118,8 +119,9 @@ public class Utilities { ArrayList attachments = new ArrayList(); MimeUtility.collectParts(message, viewables, attachments); + // Don't close the viewables attachment InputStream yet final ConversionUtilities.BodyFieldData data = - ConversionUtilities.parseBodyFields(viewables); + ConversionUtilities.parseBodyFields(viewables, false); // set body and local message values localMessage.setFlags(data.isQuotedReply, data.isQuotedForward); @@ -166,6 +168,21 @@ public class Utilities { localMessage.mFlagAttachment = true; } + // Close any parts that may still be open + for (final Part part : viewables) { + if (part.getBody() == null) { + continue; + } + try { + InputStream is = part.getBody().getInputStream(); + if (is != null) { + is.close(); + } + } catch (IOException io) { + // Ignore + } + } + // One last update of message with two updated flags localMessage.mFlagLoaded = loadStatus; diff --git a/provider_src/com/android/email/service/EmailBroadcastProcessorService.java b/provider_src/com/android/email/service/EmailBroadcastProcessorService.java index 7aa54673e..e91a49ea1 100644 --- a/provider_src/com/android/email/service/EmailBroadcastProcessorService.java +++ b/provider_src/com/android/email/service/EmailBroadcastProcessorService.java @@ -293,6 +293,10 @@ public class EmailBroadcastProcessorService extends IntentService { private void onBootCompleted() { performOneTimeInitialization(); reconcileAndStartServices(); + + // This is an special case to start IMAP PUSH via its adapter + Intent imap = new Intent(this, LegacyImapSyncAdapterService.class); + startService(imap); } private void reconcileAndStartServices() { diff --git a/provider_src/com/android/email/service/EmailServiceStub.java b/provider_src/com/android/email/service/EmailServiceStub.java index e4e757ba9..0a1264dd4 100755 --- a/provider_src/com/android/email/service/EmailServiceStub.java +++ b/provider_src/com/android/email/service/EmailServiceStub.java @@ -288,6 +288,12 @@ public abstract class EmailServiceStub extends IEmailService.Stub implements IEm mailbox = Mailbox.restoreMailboxWithId(mContext, message.mMainMailboxKey); } + if (message.mServerId == null) { + cb.loadAttachmentStatus(messageId, attachmentId, + EmailServiceStatus.MESSAGE_NOT_FOUND, 0); + return; + } + if (account == null || mailbox == null) { // If the account/mailbox are gone, just report success; the UI handles this cb.loadAttachmentStatus(messageId, attachmentId, @@ -416,7 +422,6 @@ public abstract class EmailServiceStub extends IEmailService.Stub implements IEm // actually occurs. mailbox.mUiSyncStatus = Mailbox.SYNC_STATUS_INITIAL_SYNC_NEEDED; } - mailbox.save(mContext); if (type == Mailbox.TYPE_INBOX) { inboxId = mailbox.mId; @@ -425,6 +430,7 @@ public abstract class EmailServiceStub extends IEmailService.Stub implements IEm // should start marked mailbox.mSyncInterval = 1; } + mailbox.save(mContext); } } diff --git a/provider_src/com/android/email/service/ImapService.java b/provider_src/com/android/email/service/ImapService.java index 34ecfccc7..dcd123fea 100644 --- a/provider_src/com/android/email/service/ImapService.java +++ b/provider_src/com/android/email/service/ImapService.java @@ -16,28 +16,46 @@ package com.android.email.service; +import android.app.AlarmManager; +import android.app.PendingIntent; import android.app.Service; 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.net.ConnectivityManager; +import android.net.NetworkInfo; import android.net.TrafficStats; import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; import android.os.IBinder; +import android.os.PowerManager; +import android.os.RemoteException; import android.os.SystemClock; +import android.provider.BaseColumns; import android.text.TextUtils; import android.text.format.DateUtils; +import android.util.SparseArray; +import android.util.SparseLongArray; import com.android.email.DebugUtils; +import com.android.email.EmailConnectivityManager; import com.android.email.LegacyConversions; import com.android.email.NotificationController; import com.android.email.NotificationControllerCreatorHolder; import com.android.email.R; import com.android.email.mail.Store; +import com.android.email.mail.store.ImapFolder; +import com.android.email.provider.EmailProvider; import com.android.email.provider.Utilities; import com.android.emailcommon.Logging; + +import static com.android.emailcommon.Logging.LOG_TAG; + import com.android.emailcommon.TrafficFlags; import com.android.emailcommon.internet.MimeUtility; import com.android.emailcommon.mail.AuthenticationFailedException; @@ -58,6 +76,7 @@ import com.android.emailcommon.provider.EmailContent.MessageColumns; import com.android.emailcommon.provider.EmailContent.SyncColumns; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.service.EmailServiceStatus; +import com.android.emailcommon.service.IEmailService; import com.android.emailcommon.service.SearchParams; import com.android.emailcommon.service.SyncWindow; import com.android.emailcommon.utility.AttachmentUtilities; @@ -70,12 +89,13 @@ import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class ImapService extends Service { // TODO get these from configurations or settings. private static final long QUICK_SYNC_WINDOW_MILLIS = DateUtils.DAY_IN_MILLIS; private static final long FULL_SYNC_INTERVAL_MILLIS = 4 * DateUtils.HOUR_IN_MILLIS; - private static final String TAG = "ImapService"; // The maximum number of messages to fetch in a single command. private static final int MAX_MESSAGES_TO_FETCH = 500; @@ -88,6 +108,10 @@ public class ImapService extends Service { private static final Flag[] FLAG_LIST_FLAGGED = new Flag[] { Flag.FLAGGED }; private static final Flag[] FLAG_LIST_ANSWERED = new Flag[] { Flag.ANSWERED }; + // Kick idle connection every 25 minutes + private static final int KICK_IDLE_CONNETION_TIMEOUT = 25 * 60 * 1000; + private static final int ALARM_REQUEST_KICK_IDLE_CODE = 1000; + /** * Simple cache for last search result mailbox by account and serverId, since the most common * case will be repeated use of the same mailbox @@ -104,6 +128,8 @@ public class ImapService extends Service { private static final HashMap sSearchResults = new HashMap(); + private static final ExecutorService sExecutor = Executors.newCachedThreadPool(); + /** * We write this into the serverId field of messages that will never be upsynced. */ @@ -122,34 +148,643 @@ public class ImapService extends Service { private static final String EXTRA_MESSAGE_ID = "org.codeaurora.email.intent.extra.MESSAGE_ID"; private static final String EXTRA_MESSAGE_INFO = "org.codeaurora.email.intent.extra.MESSAGE_INFO"; + private static final String ACTION_KICK_IDLE_CONNECTION = + "com.android.email.intent.action.KICK_IDLE_CONNECTION"; + private static final String EXTRA_MAILBOX = "com.android.email.intent.extra.MAILBOX"; + + private static final long RESCHEDULE_PING_DELAY = 150L; + private static final long MAX_PING_DELAY = 30 * 60 * 1000L; + private static final SparseLongArray sPingDelay = new SparseLongArray(); + + private static String sLegacyImapProtocol; private static String sMessageDecodeErrorString; + private static boolean mSyncLock; + /** * Used in ImapFolder for base64 errors. Cached here because ImapFolder does not have access * to a Context object. + * * @return Error string or empty string */ public static String getMessageDecodeErrorString() { return sMessageDecodeErrorString == null ? "" : sMessageDecodeErrorString; } + private static class ImapIdleListener implements ImapFolder.IdleCallback { + private final Context mContext; + + private final Store mStore; + private final Mailbox mMailbox; + + public ImapIdleListener(Context context, Store store, Mailbox mailbox) { + super(); + mContext = context; + mStore = store; + mMailbox = mailbox; + } + + @Override + public void onIdled() { + scheduleKickIdleConnection(); + } + + @Override + public void onNewServerChange(final boolean needSync, final List fetchMessages) { + // Instead of checking every received change, request a sync of the mailbox + if (Logging.LOGD) { + LogUtils.d(LOG_TAG, "Server notified new changes for mailbox " + mMailbox.mId); + } + cancelKickIdleConnection(); + resetPingDelay(); + + // Request a sync but wait a bit for new incoming messages from server + sExecutor.execute(new Runnable() { + @Override + public void run() { + // Selectively process all the retrieved changes + processImapIdleChangesLocked(mContext, mStore.getAccount(), mMailbox, + needSync, fetchMessages); + } + }); + } + + @Override + public void onTimeout() { + // Timeout reschedule a new ping + LogUtils.i(LOG_TAG, "Ping timeout for mailbox " + mMailbox.mId + ". Reschedule."); + cancelKickIdleConnection(); + internalUnregisterFolderIdle(); + reschedulePing(RESCHEDULE_PING_DELAY); + resetPingDelay(); + } + + @Override + public void onException(MessagingException ex) { + // Reschedule a new ping + LogUtils.e(LOG_TAG, "Ping exception for mailbox " + mMailbox.mId, ex); + cancelKickIdleConnection(); + internalUnregisterFolderIdle(); + reschedulePing(increasePingDelay()); + } + + private void internalUnregisterFolderIdle() { + ImapIdleFolderHolder holder = ImapIdleFolderHolder.getInstance(); + synchronized (holder.mIdledFolders) { + holder.mIdledFolders.remove((int) mMailbox.mId); + } + } + + private void reschedulePing(final long delay) { + // Check for connectivity before reschedule + ConnectivityManager cm = + (ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + if (activeNetwork == null || !activeNetwork.isConnected()) { + return; + } + + sExecutor.execute(new Runnable() { + @Override + public void run() { + LogUtils.i(LOG_TAG, "Reschedule delayed ping (" + delay + + ") for mailbox " + mMailbox.mId); + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + } + + try { + // Check that the account is ready for push + Account account = Account.restoreAccountWithId( + mContext, mMailbox.mAccountKey); + if (account.getSyncInterval() != Account.CHECK_INTERVAL_PUSH) { + LogUtils.i(LOG_TAG, "Account isn't declared for push: " + account.mId); + return; + } + + ImapIdleFolderHolder holder = ImapIdleFolderHolder.getInstance(); + holder.registerMailboxForIdle(mContext, account, mMailbox); + } catch (MessagingException ex) { + LogUtils.w(LOG_TAG, "Failed to register mailbox for idle. Reschedule.", ex); + reschedulePing(increasePingDelay()); + } + } + }); + } + + private void resetPingDelay() { + int index = sPingDelay.indexOfKey((int) mMailbox.mId); + if (index >= 0) { + sPingDelay.removeAt(index); + } + } + + private long increasePingDelay() { + long delay = Math.max(RESCHEDULE_PING_DELAY, sPingDelay.get((int) mMailbox.mId)); + delay = Math.min(MAX_PING_DELAY, delay * 2); + sPingDelay.put((int) mMailbox.mId, delay); + return delay; + } + + private void scheduleKickIdleConnection() { + PendingIntent pi = getKickIdleConnectionPendingIntent(); + long due = System.currentTimeMillis() + KICK_IDLE_CONNETION_TIMEOUT; + AlarmManager am = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); + am.set(AlarmManager.RTC, due, pi); + } + + private void cancelKickIdleConnection() { + AlarmManager am = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); + am.cancel(getKickIdleConnectionPendingIntent()); + } + + private PendingIntent getKickIdleConnectionPendingIntent() { + int requestCode = ALARM_REQUEST_KICK_IDLE_CODE + (int) mMailbox.mId; + Intent i = new Intent(mContext, ImapService.class); + i.setAction(ACTION_KICK_IDLE_CONNECTION); + i.putExtra(EXTRA_MAILBOX, mMailbox.mId); + return PendingIntent.getService(mContext, requestCode, + i, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + private static class ImapIdleFolderHolder { + private static ImapIdleFolderHolder sInstance; + private SparseArray mIdledFolders = new SparseArray<>(); + + private static ImapIdleFolderHolder getInstance() { + if (sInstance == null) { + sInstance = new ImapIdleFolderHolder(); + } + return sInstance; + } + + private boolean isMailboxIdled(long mailboxId) { + synchronized (mIdledFolders) { + ImapFolder folder = mIdledFolders.get((int) mailboxId); + return folder != null && folder.isIdling(); + } + } + + private boolean registerMailboxForIdle(Context context, Account account, Mailbox mailbox) + throws MessagingException { + synchronized (mIdledFolders) { + if (mailbox.mType == Mailbox.TYPE_DRAFTS || mailbox.mType == Mailbox.TYPE_OUTBOX) { + LogUtils.i(LOG_TAG, "Mailbox is not a valid idle folder: " + mailbox.mId); + return false; + } + + // Check that the account is ready for push + if (account.getSyncInterval() != Account.CHECK_INTERVAL_PUSH) { + LogUtils.d(LOG_TAG, "Account is not configured as push: " + account.mId); + return false; + } + + // Check that the folder isn't already registered + if (isMailboxIdled(mailbox.mId)) { + LogUtils.i(LOG_TAG, "Mailbox is idled already: " + mailbox.mId); + return true; + } + + if (!EmailConnectivityManager.isConnected(context)) { + LogUtils.i(LOG_TAG, "No available connection to register " + + "mailbox for idle: " + mailbox.mId); + return false; + } + + // And now just idle the folder + try { + Store remoteStore = Store.getInstance(account, context); + ImapFolder folder = mIdledFolders.get((int) mailbox.mId); + if (folder == null) { + folder = (ImapFolder) remoteStore.getFolder(mailbox.mServerId); + mIdledFolders.put((int) mailbox.mId, folder); + } + folder.open(OpenMode.READ_WRITE); + folder.startIdling(new ImapIdleListener(context, remoteStore, mailbox)); + + LogUtils.i(LOG_TAG, "Registered idle for mailbox " + mailbox.mId); + return true; + } catch (Exception ex) { + LogUtils.i(LOG_TAG, "Failed to register idle for mailbox " + mailbox.mId, ex); + } + return false; + } + } + + private void unregisterIdledMailboxLocked(long mailboxId, boolean remove) + throws MessagingException { + synchronized (mIdledFolders) { + unregisterIdledMailbox(mailboxId, remove, true); + } + } + + private void unregisterIdledMailbox(long mailboxId, boolean remove, boolean disconnect) + throws MessagingException { + // Check that the folder is already registered + if (!isMailboxIdled(mailboxId)) { + LogUtils.i(LOG_TAG, "Mailbox isn't idled yet: " + mailboxId); + return; + } + + // Stop idling + ImapFolder folder = mIdledFolders.get((int) mailboxId); + if (disconnect) { + folder.stopIdling(remove); + } + if (remove) { + mIdledFolders.remove((int) mailboxId); + } + + LogUtils.i(LOG_TAG, "Unregister idle for mailbox " + mailboxId); + } + + private void registerAccountForIdle(Context context, Account account) + throws MessagingException { + // Check that the account is ready for push + if (account.getSyncInterval() != Account.CHECK_INTERVAL_PUSH) { + LogUtils.d(LOG_TAG, "Account is not configured as push: " + account.mId); + return; + } + + LogUtils.i(LOG_TAG, "Register idle for account " + account.mId); + Cursor c = Mailbox.getLoopBackMailboxIdsForSync( + context.getContentResolver(), account.mId); + if (c != null) { + try { + boolean hasSyncMailboxes = false; + while (c.moveToNext()) { + long mailboxId = c.getLong(c.getColumnIndex(BaseColumns._ID)); + final Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); + boolean registered = isMailboxIdled(mailboxId); + if (!registered) { + registered = registerMailboxForIdle(context, account, mailbox); + } + if (!hasSyncMailboxes && registered) { + hasSyncMailboxes = registered; + } + } + + // Sync the inbox + if (!hasSyncMailboxes) { + final long inboxId = Mailbox.findMailboxOfType( + context, account.mId, Mailbox.TYPE_INBOX); + if (inboxId != Mailbox.NO_MAILBOX) { + final Mailbox inbox = Mailbox.restoreMailboxWithId(context, inboxId); + if (!isMailboxIdled(inbox.mId)) {; + registerMailboxForIdle(context, account, inbox); + } + } + } + } finally { + c.close(); + } + } + } + + private void kickAccountIdledMailboxes(Context context, Account account) + throws MessagingException { + synchronized (mIdledFolders) { + unregisterAccountIdledMailboxes(context, account.mId, true); + registerAccountForIdle(context, account); + } + } + + private void kickIdledMailbox(Context context, Mailbox mailbox, Account account) + throws MessagingException { + synchronized (mIdledFolders) { + unregisterIdledMailboxLocked(mailbox.mId, true); + registerMailboxForIdle(context, account, mailbox); + } + } + + private void unregisterAccountIdledMailboxes(Context context, long accountId, + boolean remove) { + LogUtils.i(LOG_TAG, "Unregister idle for account " + accountId); + + synchronized (mIdledFolders) { + int count = mIdledFolders.size() - 1; + for (int index = count; index >= 0; index--) { + long mailboxId = mIdledFolders.keyAt(index); + try { + Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); + if (mailbox == null || mailbox.mAccountKey == accountId) { + unregisterIdledMailbox(mailboxId, remove, true); + + LogUtils.i(LOG_TAG, "Unregister idle for mailbox " + mailboxId); + } + } catch (MessagingException ex) { + LogUtils.w(LOG_TAG, "Failed to unregister mailbox " + + mailboxId + " for account " + accountId); + } + } + } + } + + private void unregisterAllIdledMailboxes(final boolean disconnect) { + // Run away from the UI thread + sExecutor.execute(new Runnable() { + @Override + public void run() { + synchronized (mIdledFolders) { + LogUtils.i(LOG_TAG, "Unregister all idle mailboxes"); + + int count = mIdledFolders.size() - 1; + for (int index = count; index >= 0; index--) { + long mailboxId = mIdledFolders.keyAt(index); + try { + unregisterIdledMailbox(mailboxId, true, disconnect); + } catch (MessagingException ex) { + LogUtils.w(LOG_TAG, "Failed to unregister mailbox " + mailboxId); + } + } + } + } + }); + } + } + + private static class ImapEmailConnectivityManager extends EmailConnectivityManager { + private final Context mContext; + private final Handler mHandler; + private final IEmailService mService; + + private final Runnable mRegisterIdledFolderRunnable = new Runnable() { + @Override + public void run() { + sExecutor.execute(new Runnable() { + @Override + public void run() { + ImapService.registerAllImapIdleMailboxes(mContext, mService); + + // Since we could have missed some changes, request a sync + // for the IDLEd accounts + ContentResolver cr = mContext.getContentResolver(); + Cursor c = cr.query(Account.CONTENT_URI, + Account.CONTENT_PROJECTION, null, null, null); + if (c != null) { + try { + while (c.moveToNext()) { + final Account account = new Account(); + account.restore(c); + + // Only imap push accounts + if (account.getSyncInterval() != Account.CHECK_INTERVAL_PUSH) { + continue; + } + if (!isLegacyImapProtocol(mContext, account)) { + continue; + } + + // Request a "recents" sync + ImapService.requestSync(mContext, + account, Mailbox.NO_MAILBOX, false); + } + } finally { + c.close(); + } + } + } + }); + } + }; + + public ImapEmailConnectivityManager(Context context, IEmailService service) { + super(context, LOG_TAG); + mContext = context; + mHandler = new Handler(); + mService = service; + } + + @Override + public void onConnectivityRestored(int networkType) { + // Restore idled folders. Execute in background + if (Logging.LOGD) { + LogUtils.d(Logging.LOG_TAG, "onConnectivityRestored (" + + "networkType=" + networkType + ")"); + } + + // Hold the register a bit to trying to avoid unstable networking + mHandler.removeCallbacks(mRegisterIdledFolderRunnable); + mHandler.postDelayed(mRegisterIdledFolderRunnable, 10000); + } + + @Override + public void onConnectivityLost(int networkType) { + // Unlink idled folders. Execute in background + if (Logging.LOGD) { + LogUtils.d(Logging.LOG_TAG, "onConnectivityLost (" + + "networkType=" + networkType + ")"); + } + sExecutor.execute(new Runnable() { + @Override + public void run() { + // Only remove references. We have no network to kill idled + // connections + ImapIdleFolderHolder.getInstance().unregisterAllIdledMailboxes(false); + } + }); + } + } + + private static class LocalChangesContentObserver extends ContentObserver { + private Context mContext; + + public LocalChangesContentObserver(Context context, Handler handler) { + super(handler); + mContext = context; + } + + @Override + public void onChange(boolean selfChange, Uri uri) { + // what changed? + try { + List segments = uri.getPathSegments(); + final String type = segments.get(0); + final String op = segments.get(1); + final long id = Long.parseLong(uri.getLastPathSegment()); + + // Run the changes processor outside the ui thread + sExecutor.execute(new Runnable() { + @Override + public void run() { + // Apply the change + if (type.equals("account")) { + processAccountChanged(op, id); + } else if (type.equals("mailbox")) { + processMailboxChanged(op, id); + } else if (type.equals("message")) { + processMessageChanged(op, id); + } + } + }); + } catch (Exception ex) { + return; + } + } + + private void processAccountChanged(String op, long id) { + // For delete operations we can't fetch the account, so process it first + if (op.equals(EmailProvider.NOTIFICATION_OP_DELETE)) { + ImapIdleFolderHolder.getInstance() + .unregisterAccountIdledMailboxes(mContext, id, true); + stopImapPushServiceIfNecessary(mContext); + return; + } + + Account account = Account.restoreAccountWithId(mContext, id); + if (account == null) { + return; + } + if (!isLegacyImapProtocol(mContext, account)) { + // The account isn't an imap account + return; + } + + try { + final ImapIdleFolderHolder holder = ImapIdleFolderHolder.getInstance(); + if (op.equals(EmailProvider.NOTIFICATION_OP_UPDATE)) { + holder.kickAccountIdledMailboxes(mContext, account); + } else if (op.equals(EmailProvider.NOTIFICATION_OP_INSERT)) { + if (account.getSyncInterval() == Account.CHECK_INTERVAL_PUSH) { + holder.registerAccountForIdle(mContext, account); + } + } + } catch (MessagingException me) { + LogUtils.e(LOG_TAG, "Failed to process imap account " + id + " changes.", me); + } + + // Check if service should be started/stopped + stopImapPushServiceIfNecessary(mContext); + } + + private void processMailboxChanged(String op, long id) { + // For delete operations we can't fetch the mailbox, so process it first + if (op.equals(EmailProvider.NOTIFICATION_OP_DELETE)) { + try { + ImapIdleFolderHolder.getInstance().unregisterIdledMailboxLocked(id, true); + } catch (MessagingException me) { + LogUtils.e(LOG_TAG, "Failed to process imap mailbox " + id + " changes.", me); + } + return; + } + + Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, id); + if (mailbox == null) { + return; + } + Account account = Account.restoreAccountWithId(mContext, mailbox.mAccountKey); + if (account == null) { + return; + } + if (!isLegacyImapProtocol(mContext, account)) { + // The account isn't an imap account + return; + } + + try { + final ImapIdleFolderHolder holder = ImapIdleFolderHolder.getInstance(); + if (op.equals(EmailProvider.NOTIFICATION_OP_UPDATE)) { + // Only apply if syncInterval has changed + boolean registered = holder.isMailboxIdled(id); + boolean toRegister = mailbox.mSyncInterval == 1 + && account.getSyncInterval() == Account.CHECK_INTERVAL_PUSH; + if (registered != toRegister) { + if (registered) { + holder.unregisterIdledMailboxLocked(id, true); + } + if (toRegister) { + holder.registerMailboxForIdle(mContext, account, mailbox); + } + } + } else if (op.equals(EmailProvider.NOTIFICATION_OP_INSERT)) { + if (account.getSyncInterval() == Account.CHECK_INTERVAL_PUSH) { + holder.registerMailboxForIdle(mContext, account, mailbox); + } + } + } catch (MessagingException me) { + LogUtils.e(LOG_TAG, "Failed to process imap mailbox " + id + " changes.", me); + } + } + + private void processMessageChanged(String op, long id) { + if (mSyncLock) { + return; + } + EmailContent.Message msg = EmailContent.Message.restoreMessageWithId(mContext, id); + if (msg == null) { + return; + } + Account account = Account.restoreAccountWithId(mContext, msg.mAccountKey); + if (account == null) { + return; + } + if (!isLegacyImapProtocol(mContext, account)) { + // The account isn't an imap account + return; + } + if (account.getSyncInterval() != Account.CHECK_INTERVAL_PUSH) { + return; + } + + try { + Store remoteStore = Store.getInstance(account, mContext); + processPendingActionsSynchronous(mContext, account, remoteStore, false); + } catch (MessagingException me) { + LogUtils.e(LOG_TAG, "Failed to process imap message " + id + " changes.", me); + } + } + } + + private ImapEmailConnectivityManager mConnectivityManager; + private LocalChangesContentObserver mLocalChangesObserver; + private Handler mServiceHandler; + @Override public void onCreate() { super.onCreate(); sMessageDecodeErrorString = getString(R.string.message_decode_error); + mServiceHandler = new Handler(); + + // Initialize the email provider and the listeners/observers + EmailContent.init(this); + mConnectivityManager = new ImapEmailConnectivityManager(this, mBinder); + mLocalChangesObserver = new LocalChangesContentObserver(this, mServiceHandler); + + // Register observers + getContentResolver().registerContentObserver( + Account.SYNC_SETTING_CHANGED_URI, true, mLocalChangesObserver); + getContentResolver().registerContentObserver( + Mailbox.SYNC_SETTING_CHANGED_URI, true, mLocalChangesObserver); + getContentResolver().registerContentObserver( + EmailContent.Message.NOTIFIER_URI, true, mLocalChangesObserver); + } + + @Override + public void onDestroy() { + // Unregister services + ImapIdleFolderHolder.getInstance().unregisterAllIdledMailboxes(true); + mConnectivityManager.unregister(); + getContentResolver().unregisterContentObserver(mLocalChangesObserver); + + super.onDestroy(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + return Service.START_STICKY; + } final String action = intent.getAction(); if (Logging.LOGD) { LogUtils.d(Logging.LOG_TAG, "Action: ", action); } final long accountId = intent.getLongExtra(EXTRA_ACCOUNT, -1); - Context context = getApplicationContext(); + final Context context = getApplicationContext(); if (ACTION_CHECK_MAIL.equals(action)) { final long inboxId = Mailbox.findMailboxOfType(context, accountId, Mailbox.TYPE_INBOX); @@ -181,6 +816,10 @@ public class ImapService extends Service { Account.getAccountForMessageId(context, messageId), remoteStore, true); } catch (Exception e) { LogUtils.d(Logging.LOG_TAG, "RemoteException " + e); + } finally { + if (remoteStore != null) { + remoteStore.closeConnections(); + } } } else if (ACTION_MESSAGE_READ.equals(action)) { final long messageId = intent.getLongExtra(EXTRA_MESSAGE_ID, -1); @@ -202,6 +841,10 @@ public class ImapService extends Service { Account.getAccountForMessageId(context, messageId), remoteStore, true); } catch (Exception e){ LogUtils.d(Logging.LOG_TAG, "RemoteException " + e); + } finally { + if (remoteStore != null) { + remoteStore.closeConnections(); + } } } else if (ACTION_MOVE_MESSAGE.equals(action)) { final long messageId = intent.getLongExtra(EXTRA_MESSAGE_ID, -1); @@ -226,6 +869,10 @@ public class ImapService extends Service { Account.getAccountForMessageId(context, messageId),remoteStore, true); } catch (Exception e){ LogUtils.d(Logging.LOG_TAG, "RemoteException " + e); + } finally { + if (remoteStore != null) { + remoteStore.closeConnections(); + } } } else if (ACTION_SEND_PENDING_MAIL.equals(action)) { if (Logging.LOGD) { @@ -240,6 +887,48 @@ public class ImapService extends Service { } catch (Exception e) { LogUtils.e(Logging.LOG_TAG, "RemoteException " + e); } + } else if (ACTION_KICK_IDLE_CONNECTION.equals(action)) { + if (Logging.LOGD) { + LogUtils.d(Logging.LOG_TAG, "action: Send Pending Mail "+accountId); + } + final long mailboxId = intent.getLongExtra(EXTRA_MAILBOX, -1); + if (mailboxId <= -1 ) { + return START_NOT_STICKY; + } + + sExecutor.execute(new Runnable() { + @Override + public void run() { + Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); + if (mailbox == null) { + return; + } + Account account = Account.restoreAccountWithId(context, mailbox.mAccountKey); + if (account == null) { + return; + } + + Store remoteStore = null; + try { + // Since we were idling, just perform a full sync of the mailbox to ensure + // we have all the items before kick the connection + remoteStore = Store.getInstance(account, context); + synchronizeMailboxGeneric(context, account, remoteStore, + mailbox, false, true); + + // Kick mailbox + ImapIdleFolderHolder holder = ImapIdleFolderHolder.getInstance(); + holder.kickIdledMailbox(context, mailbox, account); + } catch (Exception e) { + LogUtils.e(Logging.LOG_TAG,"Failed to kick idled connection " + + "for mailbox " + mailboxId, e); + } finally { + if (remoteStore != null) { + remoteStore.closeConnections(); + } + } + } + }); } return Service.START_STICKY; @@ -259,6 +948,26 @@ public class ImapService extends Service { } return 0; } + + @Override + public void pushModify(long accountId) throws RemoteException { + final Context context = ImapService.this; + final Account account = Account.restoreAccountWithId(context, accountId); + if (account.getSyncInterval() != Account.CHECK_INTERVAL_PUSH) { + LogUtils.i(LOG_TAG,"Idle (pushModify) isn't avaliable for account " + accountId); + ImapIdleFolderHolder holder = ImapIdleFolderHolder.getInstance(); + holder.unregisterAccountIdledMailboxes(context, account.mId, true); + return; + } + + LogUtils.i(LOG_TAG,"Register idle (pushModify) account " + accountId); + try { + ImapIdleFolderHolder holder = ImapIdleFolderHolder.getInstance(); + holder.registerAccountForIdle(context, account); + } catch (MessagingException ex) { + LogUtils.d(LOG_TAG, "Failed to modify push for account " + accountId); + } + } }; @Override @@ -267,6 +976,89 @@ public class ImapService extends Service { return mBinder; } + protected static void registerAllImapIdleMailboxes(Context context, IEmailService service) { + ContentResolver cr = context.getContentResolver(); + Cursor c = cr.query(Account.CONTENT_URI, Account.CONTENT_PROJECTION, null, null, null); + if (c != null) { + try { + while (c.moveToNext()) { + final Account account = new Account(); + account.restore(c); + + // Only imap push accounts + if (account.getSyncInterval() != Account.CHECK_INTERVAL_PUSH) { + continue; + } + if (!isLegacyImapProtocol(context, account)) { + continue; + } + + try { + service.pushModify(account.mId); + } catch (RemoteException ex) { + LogUtils.d(LOG_TAG, "Failed to call pushModify for account " + account.mId); + } + } + } finally { + c.close(); + } + } + } + + private static void requestSync(Context context, Account account, long mailbox, boolean full) { + if (Logging.LOGD) { + LogUtils.d(LOG_TAG, "Request sync due to idle response for mailbox " + mailbox); + } + + final EmailServiceUtils.EmailServiceInfo info = EmailServiceUtils.getServiceInfoForAccount( + context, account.mId); + final android.accounts.Account acct = new android.accounts.Account( + account.mEmailAddress, info.accountType); + Bundle extras = null; + if (mailbox != Mailbox.NO_MAILBOX) { + extras = Mailbox.createSyncBundle(mailbox); + } else { + extras = new Bundle(); + } + extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, false); + extras.putBoolean(ContentResolver.SYNC_EXTRAS_DO_NOT_RETRY, true); + extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, full); + ContentResolver.requestSync(acct, EmailContent.AUTHORITY, extras); + } + + protected static final void stopImapPushServiceIfNecessary(Context context) { + ContentResolver cr = context.getContentResolver(); + Cursor c = cr.query(Account.CONTENT_URI, Account.CONTENT_PROJECTION,null, null, null); + if (c != null) { + try { + while (c.moveToNext()) { + final Account account = new Account(); + account.restore(c); + + // Only imap push accounts + if (account.getSyncInterval() != Account.CHECK_INTERVAL_PUSH || + !ImapService.isLegacyImapProtocol(context, account)) { + continue; + } + + return; + } + } finally { + c.close(); + } + } + + // Stop the service + context.stopService(new Intent(context, LegacyImapSyncAdapterService.class)); + } + + public static boolean isLegacyImapProtocol(Context ctx, Account acct) { + if (sLegacyImapProtocol == null) { + sLegacyImapProtocol = ctx.getString(R.string.protocol_legacy_imap); + } + return acct.getProtocol(ctx).equals(sLegacyImapProtocol); + } + /** * Start foreground synchronization of the specified folder. This is called by * synchronizeMailbox or checkMail. @@ -278,13 +1070,23 @@ public class ImapService extends Service { final Account account, final Mailbox folder, final boolean loadMore, final boolean uiRefresh) throws MessagingException { TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(context, account)); - final NotificationController nc = - NotificationControllerCreatorHolder.getInstance(context); + final NotificationController nc = NotificationControllerCreatorHolder.getInstance(context); Store remoteStore = null; + ImapIdleFolderHolder imapHolder = ImapIdleFolderHolder.getInstance(); try { + mSyncLock = true; + + // Unregister the imap idle + if (account.getSyncInterval() == Account.CHECK_INTERVAL_PUSH) { + imapHolder.unregisterIdledMailboxLocked(folder.mId, false); + } else { + imapHolder.unregisterAccountIdledMailboxes(context, account.mId, false); + } + remoteStore = Store.getInstance(account, context); processPendingActionsSynchronous(context, account, remoteStore, uiRefresh); synchronizeMailboxGeneric(context, account, remoteStore, folder, loadMore, uiRefresh); + // Clear authentication notification for this account nc.cancelLoginFailedNotification(account.mId); } catch (MessagingException e) { @@ -297,9 +1099,16 @@ public class ImapService extends Service { } throw e; } finally { + mSyncLock = false; + if (remoteStore != null) { remoteStore.closeConnections(); } + + // Register the imap idle again + if (account.getSyncInterval() == Account.CHECK_INTERVAL_PUSH) { + imapHolder.registerMailboxForIdle(context, account, folder); + } } // TODO: Rather than use exceptions as logic above, return the status and handle it // correctly in caller. @@ -825,6 +1634,255 @@ public class ImapService extends Service { remoteFolder.close(false); } + private synchronized static void processImapFetchChanges(Context ctx, Account acct, + Mailbox mailbox, List uids) throws MessagingException { + + PowerManager pm = (PowerManager) ctx.getSystemService(Context.POWER_SERVICE); + PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + "Imap IDLE Sync WakeLock"); + + NotificationController nc = null; + Store remoteStore = null; + ImapIdleFolderHolder imapHolder = null; + + try { + mSyncLock = true; + wl.acquire(); + + TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(ctx, acct)); + nc = NotificationControllerCreatorHolder.getInstance(ctx); + + remoteStore = Store.getInstance(acct, ctx); + imapHolder = ImapIdleFolderHolder.getInstance(); + + final ContentResolver resolver = ctx.getContentResolver(); + + // Don't sync if account is not set to idled + if (acct.getSyncInterval() != Account.CHECK_INTERVAL_PUSH) { + return; + } + + // 1. Open the remote store & folder + ImapFolder remoteFolder; + synchronized (imapHolder.mIdledFolders) { + remoteFolder = imapHolder.mIdledFolders.get((int) mailbox.mId); + } + if (remoteFolder == null || remoteFolder.isIdling()) { + remoteFolder = (ImapFolder) remoteStore.getFolder(mailbox.mServerId); + } + if (!remoteFolder.exists()) { + return; + } + remoteFolder.open(OpenMode.READ_WRITE); + if (remoteFolder.getMode() != OpenMode.READ_WRITE) { + return; + } + + // 1.- Retrieve the messages + Message[] remoteMessages = remoteFolder.getMessages( + uids.toArray(new String[uids.size()]), null); + + // 2.- Refresh flags + FetchProfile fp = new FetchProfile(); + fp.add(FetchProfile.Item.FLAGS); + remoteFolder.fetch(remoteMessages, fp, null); + + boolean remoteSupportsSeen = false; + boolean remoteSupportsFlagged = false; + boolean remoteSupportsAnswered = false; + for (Flag flag : remoteFolder.getPermanentFlags()) { + if (flag == Flag.SEEN) { + remoteSupportsSeen = true; + } + if (flag == Flag.FLAGGED) { + remoteSupportsFlagged = true; + } + if (flag == Flag.ANSWERED) { + remoteSupportsAnswered = true; + } + } + + // 3.- Retrieve a reference of the local messages + HashMap localMessageMap = new HashMap<>(); + for (Message remoteMessage : remoteMessages) { + Cursor localUidCursor = null; + try { + localUidCursor = resolver.query( + EmailContent.Message.CONTENT_URI, + LocalMessageInfo.PROJECTION, + EmailContent.MessageColumns.ACCOUNT_KEY + "=?" + + " AND " + MessageColumns.MAILBOX_KEY + "=?" + + " AND " + MessageColumns.SERVER_ID + ">=?", + new String[] { + String.valueOf(acct.mId), + String.valueOf(mailbox.mId), + String.valueOf(remoteMessage.getUid()) }, + null); + if (localUidCursor != null && localUidCursor.moveToNext()) { + LocalMessageInfo info = new LocalMessageInfo(localUidCursor); + localMessageMap.put(info.mServerId, info); + } + } finally { + if (localUidCursor != null) { + localUidCursor.close(); + } + } + } + + // 5.- Add to the list of new messages + final ArrayList unseenMessages = new ArrayList(); + final ArrayList unsyncedMessages = new ArrayList(); + for (Message remoteMessage : remoteMessages) { + LocalMessageInfo localMessage = localMessageMap.get(remoteMessage.getUid()); + + // localMessage == null -> message has never been created (not even headers) + // mFlagLoaded = UNLOADED -> message created, but none of body loaded + // mFlagLoaded = PARTIAL -> message created, a "sane" amt of body has been loaded + // mFlagLoaded = COMPLETE -> message body has been completely loaded + // mFlagLoaded = DELETED -> message has been deleted + // Only the first two of these are "unsynced", so let's retrieve them + if (localMessage == null || + (localMessage.mFlagLoaded == EmailContent.Message.FLAG_LOADED_UNLOADED) || + (localMessage.mFlagLoaded == EmailContent.Message.FLAG_LOADED_PARTIAL)) { + unsyncedMessages.add(remoteMessage); + } + } + + // 6. Download basic info about the new/unloaded messages (if any) + /* + * Fetch the flags and envelope only of the new messages. This is intended to get us + * critical data as fast as possible, and then we'll fill in the details. + */ + if (unsyncedMessages.size() > 0) { + downloadFlagAndEnvelope(ctx, acct, mailbox, remoteFolder, unsyncedMessages, + localMessageMap, unseenMessages); + } + + // 7. Update SEEN/FLAGGED/ANSWERED (star) flags + if (remoteSupportsSeen || remoteSupportsFlagged || remoteSupportsAnswered) { + for (Message remoteMessage : remoteMessages) { + LocalMessageInfo localMessageInfo = localMessageMap.get(remoteMessage.getUid()); + if (localMessageInfo == null) { + continue; + } + boolean localSeen = localMessageInfo.mFlagRead; + boolean remoteSeen = remoteMessage.isSet(Flag.SEEN); + boolean newSeen = (remoteSupportsSeen && (remoteSeen != localSeen)); + boolean localFlagged = localMessageInfo.mFlagFavorite; + boolean remoteFlagged = remoteMessage.isSet(Flag.FLAGGED); + boolean newFlagged = (remoteSupportsFlagged && (localFlagged != remoteFlagged)); + int localFlags = localMessageInfo.mFlags; + boolean localAnswered = (localFlags & + EmailContent.Message.FLAG_REPLIED_TO) != 0; + boolean remoteAnswered = remoteMessage.isSet(Flag.ANSWERED); + boolean newAnswered = (remoteSupportsAnswered && + (localAnswered != remoteAnswered)); + if (newSeen || newFlagged || newAnswered) { + Uri uri = ContentUris.withAppendedId( + EmailContent.Message.CONTENT_URI, localMessageInfo.mId); + ContentValues updateValues = new ContentValues(); + updateValues.put(MessageColumns.FLAG_READ, remoteSeen); + updateValues.put(MessageColumns.FLAG_FAVORITE, remoteFlagged); + if (remoteAnswered) { + localFlags |= EmailContent.Message.FLAG_REPLIED_TO; + } else { + localFlags &= ~EmailContent.Message.FLAG_REPLIED_TO; + } + updateValues.put(MessageColumns.FLAGS, localFlags); + resolver.update(uri, updateValues, null, null); + } + } + } + + // 8.- Remove remote deleted messages + for (final Message remoteMessage : remoteMessages) { + if (remoteMessage.isSet(Flag.DELETED)) { + LocalMessageInfo info = localMessageMap.get(remoteMessage.getUid()); + if (info == null) { + continue; + } + + // Delete associated data (attachment files) + // Attachment & Body records are auto-deleted when we delete the Message record + AttachmentUtilities.deleteAllAttachmentFiles(ctx, acct.mId, info.mId); + + // Delete the message itself + final Uri uriToDelete = ContentUris.withAppendedId( + EmailContent.Message.CONTENT_URI, info.mId); + resolver.delete(uriToDelete, null, null); + + // Delete extra rows (e.g. updated or deleted) + final Uri updateRowToDelete = ContentUris.withAppendedId( + EmailContent.Message.UPDATED_CONTENT_URI, info.mId); + resolver.delete(updateRowToDelete, null, null); + final Uri deleteRowToDelete = ContentUris.withAppendedId( + EmailContent.Message.DELETED_CONTENT_URI, info.mId); + resolver.delete(deleteRowToDelete, null, null); + } + } + + // 9.- Load unsynced messages + loadUnsyncedMessages(ctx, acct, remoteFolder, unsyncedMessages, mailbox); + + // 10. Remove messages that are in the local store but no in the current sync window + int syncLookBack = mailbox.mSyncLookback == SyncWindow.SYNC_WINDOW_ACCOUNT + ? acct.mSyncLookback + : mailbox.mSyncLookback; + long endDate = System.currentTimeMillis() - + (SyncWindow.toDays(syncLookBack) * DateUtils.DAY_IN_MILLIS); + LogUtils.d(Logging.LOG_TAG, "full sync: original window: now - " + endDate); + for (final LocalMessageInfo info : localMessageMap.values()) { + // If this message is inside our sync window, and we cannot find it in our list + // of remote messages, then we know it's been deleted from the server. + if (info.mTimestamp < endDate) { + // Delete associated data (attachment files) + // Attachment & Body records are auto-deleted when we delete the Message record + AttachmentUtilities.deleteAllAttachmentFiles(ctx, acct.mId, info.mId); + + // Delete the message itself + final Uri uriToDelete = ContentUris.withAppendedId( + EmailContent.Message.CONTENT_URI, info.mId); + resolver.delete(uriToDelete, null, null); + + // Delete extra rows (e.g. updated or deleted) + final Uri updateRowToDelete = ContentUris.withAppendedId( + EmailContent.Message.UPDATED_CONTENT_URI, info.mId); + resolver.delete(updateRowToDelete, null, null); + final Uri deleteRowToDelete = ContentUris.withAppendedId( + EmailContent.Message.DELETED_CONTENT_URI, info.mId); + resolver.delete(deleteRowToDelete, null, null); + } + } + + // Clear authentication notification for this account + nc.cancelLoginFailedNotification(acct.mId); + + } catch (MessagingException ex) { + if (Logging.LOGD) { + LogUtils.d(Logging.LOG_TAG, "processImapFetchChanges", ex); + } + if (ex instanceof AuthenticationFailedException) { + // Generate authentication notification + if (nc != null) { + nc.showLoginFailedNotificationSynchronous(acct.mId, true /* incoming */); + } + } + throw ex; + } finally { + mSyncLock = false; + wl.release(); + + if (remoteStore != null) { + remoteStore.closeConnections(); + + // Register the imap idle again + if (imapHolder != null && acct.getSyncInterval() == Account.CHECK_INTERVAL_PUSH) { + imapHolder.registerMailboxForIdle(ctx, acct, mailbox); + } + } + } + } + /** * Find messages in the updated table that need to be written back to server. * @@ -1701,4 +2759,70 @@ public class ImapService extends Service { return numSearchResults; } + + private static synchronized void processImapIdleChangesLocked(Context context, Account account, + Mailbox mailbox, boolean needSync, List fetchMessages) { + + // Process local to server changes first + Store remoteStore = null; + try { + remoteStore = Store.getInstance(account, context); + processPendingActionsSynchronous(context, account, remoteStore, false); + } catch (MessagingException me) { + // Ignore + } finally { + if (remoteStore != null) { + remoteStore.closeConnections(); + } + } + + // If the request rebased the maximum time without a full sync, then instead of fetch + // the changes just perform a full sync + final long timeSinceLastFullSync = SystemClock.elapsedRealtime() - + mailbox.mLastFullSyncTime; + final boolean fullSync = timeSinceLastFullSync >= FULL_SYNC_INTERVAL_MILLIS + || timeSinceLastFullSync < 0; + if (fullSync) { + needSync = true; + fetchMessages.clear(); + + if (Logging.LOGD) { + LogUtils.d(LOG_TAG, "Full sync required for mailbox " + mailbox.mId + + " because is exceded the maximum time without a full sync."); + } + } + + final int msgToFetchSize = fetchMessages.size(); + if (Logging.LOGD) { + LogUtils.d(LOG_TAG, "Processing IDLE changes for mailbox " + mailbox.mId + + ": need sync " + needSync + ", " + msgToFetchSize + " fetch messages"); + } + + boolean syncRequested = false; + try { + // Sync fetch messages only if we are not going to perform a full sync + if (msgToFetchSize > 0 && msgToFetchSize < MAX_MESSAGES_TO_FETCH && !needSync) { + processImapFetchChanges(context, account, mailbox, fetchMessages); + } + if (needSync || msgToFetchSize > MAX_MESSAGES_TO_FETCH) { + // With idle we fetched as much as possible. If as resync is required, then + // if should be a full sync + requestSync(context, account, mailbox.mId, true); + syncRequested = true; + } + } catch (MessagingException ex) { + LogUtils.w(LOG_TAG, "Failed to process imap idle changes for mailbox " + mailbox.mId); + } + + // In case no sync happens, re-add idle status + try { + if (!syncRequested && account.getSyncInterval() == Account.CHECK_INTERVAL_PUSH) { + final ImapIdleFolderHolder holder = ImapIdleFolderHolder.getInstance(); + holder.registerMailboxForIdle(context, account, mailbox); + } + } catch (MessagingException ex) { + LogUtils.w(LOG_TAG, "Failed to readd imap idle after no sync " + + "for mailbox " + mailbox.mId); + } + } } diff --git a/provider_src/com/android/email/service/LegacyImapSyncAdapterService.java b/provider_src/com/android/email/service/LegacyImapSyncAdapterService.java index 1f6b6195e..1b5a36b61 100644 --- a/provider_src/com/android/email/service/LegacyImapSyncAdapterService.java +++ b/provider_src/com/android/email/service/LegacyImapSyncAdapterService.java @@ -16,5 +16,139 @@ package com.android.email.service; +import static com.android.emailcommon.Logging.LOG_TAG; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import android.content.AbstractThreadedSyncAdapter; +import android.content.ComponentName; +import android.content.ContentProviderClient; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SyncResult; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.IBinder; +import android.os.PowerManager; +import android.text.format.DateUtils; + +import com.android.emailcommon.Logging; +import com.android.emailcommon.provider.Mailbox; +import com.android.emailcommon.service.IEmailService; +import com.android.mail.utils.LogUtils; + public class LegacyImapSyncAdapterService extends PopImapSyncAdapterService { -} \ No newline at end of file + + // The call to ServiceConnection.onServiceConnected is asynchronous to bindService. It's + // possible for that to be delayed if, in which case, a call to onPerformSync + // could occur before we have a connection to the service. + // In onPerformSync, if we don't yet have our ImapService, we will wait for up to 10 + // seconds for it to appear. If it takes longer than that, we will fail the sync. + private static final long MAX_WAIT_FOR_SERVICE_MS = 10 * DateUtils.SECOND_IN_MILLIS; + + private static final ExecutorService sExecutor = Executors.newCachedThreadPool(); + + private IEmailService mImapService; + + private final ServiceConnection mConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + if (Logging.LOGD) { + LogUtils.v(LOG_TAG, "onServiceConnected"); + } + synchronized (mConnection) { + mImapService = IEmailService.Stub.asInterface(binder); + mConnection.notify(); + + // We need to run this task in the background (not in UI-Thread) + sExecutor.execute(new Runnable() { + @Override + public void run() { + final Context context = LegacyImapSyncAdapterService.this; + ImapService.registerAllImapIdleMailboxes(context, mImapService); + } + }); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mImapService = null; + } + }; + + protected class ImapSyncAdapterImpl extends SyncAdapterImpl { + public ImapSyncAdapterImpl(Context context) { + super(context); + } + + @Override + public void onPerformSync(android.accounts.Account account, Bundle extras, + String authority, ContentProviderClient provider, SyncResult syncResult) { + + final Context context = LegacyImapSyncAdapterService.this; + PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, + "Imap Sync WakeLock"); + try { + wl.acquire(); + + if (!waitForService()) { + // The service didn't connect, nothing we can do. + return; + } + + if (!Mailbox.isPushOnlyExtras(extras)) { + super.onPerformSync(account, extras, authority, provider, syncResult); + } + + // Check if IMAP push service is necessary + ImapService.stopImapPushServiceIfNecessary(context); + + } finally { + wl.release(); + } + } + } + + public AbstractThreadedSyncAdapter getSyncAdapter() { + return new ImapSyncAdapterImpl(getApplicationContext()); + } + + @Override + public void onCreate() { + super.onCreate(); + bindService(new Intent(this, ImapService.class), mConnection, Context.BIND_AUTO_CREATE); + startService(new Intent(this, LegacyImapSyncAdapterService.class)); + } + + @Override + public void onDestroy() { + unbindService(mConnection); + super.onDestroy(); + } + + private final boolean waitForService() { + synchronized(mConnection) { + if (mImapService == null) { + if (Logging.LOGD) { + LogUtils.v(LOG_TAG, "ImapService not yet connected"); + } + try { + mConnection.wait(MAX_WAIT_FOR_SERVICE_MS); + } catch (InterruptedException e) { + LogUtils.wtf(LOG_TAG, "InterrupedException waiting for ImapService to connect"); + return false; + } + if (mImapService == null) { + LogUtils.wtf(LOG_TAG, "timed out waiting for ImapService to connect"); + return false; + } + } + } + return true; + } + +} diff --git a/provider_src/com/android/email/service/PopImapSyncAdapterService.java b/provider_src/com/android/email/service/PopImapSyncAdapterService.java index 432cdd107..5e19478dd 100644 --- a/provider_src/com/android/email/service/PopImapSyncAdapterService.java +++ b/provider_src/com/android/email/service/PopImapSyncAdapterService.java @@ -49,7 +49,7 @@ import java.util.ArrayList; public class PopImapSyncAdapterService extends Service { private static final String TAG = "PopImapSyncService"; - private SyncAdapterImpl mSyncAdapter = null; + private AbstractThreadedSyncAdapter mSyncAdapter = null; private static String sPop3Protocol; private static String sLegacyImapProtocol; @@ -58,7 +58,7 @@ public class PopImapSyncAdapterService extends Service { super(); } - private static class SyncAdapterImpl extends AbstractThreadedSyncAdapter { + static class SyncAdapterImpl extends AbstractThreadedSyncAdapter { public SyncAdapterImpl(Context context) { super(context, true /* autoInitialize */); } @@ -71,10 +71,14 @@ public class PopImapSyncAdapterService extends Service { } } + public AbstractThreadedSyncAdapter getSyncAdapter() { + return new SyncAdapterImpl(getApplicationContext()); + } + @Override public void onCreate() { super.onCreate(); - mSyncAdapter = new SyncAdapterImpl(getApplicationContext()); + mSyncAdapter = getSyncAdapter(); } @Override @@ -101,14 +105,14 @@ public class PopImapSyncAdapterService extends Service { return false; } - private static void sync(final Context context, final long mailboxId, + private static boolean sync(final Context context, final long mailboxId, final Bundle extras, final SyncResult syncResult, final boolean uiRefresh, final int deltaMessageCount) { TempDirectory.setTempDirectory(context); Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); - if (mailbox == null) return; + if (mailbox == null) return false; Account account = Account.restoreAccountWithId(context, mailbox.mAccountKey); - if (account == null) return; + if (account == null) return false; ContentResolver resolver = context.getContentResolver(); if ((mailbox.mType != Mailbox.TYPE_OUTBOX) && !loadsFromServer(context, mailbox, account)) { @@ -116,7 +120,7 @@ public class PopImapSyncAdapterService extends Service { // updates table and return resolver.delete(Message.UPDATED_CONTENT_URI, MessageColumns.MAILBOX_KEY + "=?", new String[] {Long.toString(mailbox.mId)}); - return; + return true; } LogUtils.d(TAG, "About to sync mailbox: " + mailbox.mDisplayName); @@ -147,6 +151,7 @@ public class PopImapSyncAdapterService extends Service { } EmailServiceStatus.syncMailboxStatus(resolver, extras, mailboxId, status, 0, lastSyncResult); + return true; } } catch (MessagingException e) { final int type = e.getExceptionType(); @@ -186,6 +191,7 @@ public class PopImapSyncAdapterService extends Service { values.put(Mailbox.SYNC_TIME, System.currentTimeMillis()); resolver.update(mailboxUri, values, null, null); } + return false; } /** @@ -247,7 +253,8 @@ public class PopImapSyncAdapterService extends Service { // from the account settings. Otherwise just sync the inbox. if (info.offerLookback) { mailboxIds = getLoopBackMailboxIdsForSync(context, acct); - } else { + } + if (mailboxIds.length == 0) { final long inboxId = Mailbox.findMailboxOfType(context, acct.mId, Mailbox.TYPE_INBOX); if (inboxId != Mailbox.NO_MAILBOX) { @@ -262,9 +269,20 @@ public class PopImapSyncAdapterService extends Service { extras.getBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, false); int deltaMessageCount = extras.getInt(Mailbox.SYNC_EXTRA_DELTA_MESSAGE_COUNT, 0); + boolean success = mailboxIds.length > 0; for (long mailboxId : mailboxIds) { - sync(context, mailboxId, extras, syncResult, uiRefresh, - deltaMessageCount); + boolean result = sync(context, mailboxId, extras, syncResult, + uiRefresh, deltaMessageCount); + if (!result) { + success = false; + } + } + + // Initial sync performed? + if (success) { + // All mailboxes (that need a sync) are now synced. Assume we + // have a valid sync key, in case this account has push support + markAsInitialSyncKey(context, acct.mId); } } } @@ -278,6 +296,14 @@ public class PopImapSyncAdapterService extends Service { } } + private static void markAsInitialSyncKey(Context context, long accountId) { + ContentResolver resolver = context.getContentResolver(); + Uri accountUri = ContentUris.withAppendedId(Account.CONTENT_URI, accountId); + ContentValues values = new ContentValues(); + values.put(AccountColumns.SYNC_KEY, "1"); + resolver.update(accountUri, values, null, null); + } + private static boolean isLegacyImapProtocol(Context ctx, Account acct) { if (sLegacyImapProtocol == null) { sLegacyImapProtocol = ctx.getString(R.string.protocol_legacy_imap); diff --git a/res/xml/services.xml b/res/xml/services.xml index 1bd882306..aaebd6c90 100644 --- a/res/xml/services.xml +++ b/res/xml/services.xml @@ -74,8 +74,8 @@ email:serviceClass="com.android.email.service.ImapService" email:port="143" email:portSsl="993" - email:syncIntervalStrings="@array/account_settings_check_frequency_entries" - email:syncIntervals="@array/account_settings_check_frequency_values" + email:syncIntervalStrings="@array/account_settings_check_frequency_entries_push" + email:syncIntervals="@array/account_settings_check_frequency_values_push" email:defaultSyncInterval="mins15" email:offerTls="true" diff --git a/res/xml/syncadapter_legacy_imap.xml b/res/xml/syncadapter_legacy_imap.xml index 6ad6ee140..09be31a6d 100644 --- a/res/xml/syncadapter_legacy_imap.xml +++ b/res/xml/syncadapter_legacy_imap.xml @@ -23,5 +23,6 @@ diff --git a/src/com/android/email/activity/setup/AccountCheckSettingsFragment.java b/src/com/android/email/activity/setup/AccountCheckSettingsFragment.java index a9c1a9691..f11f54cd2 100644 --- a/src/com/android/email/activity/setup/AccountCheckSettingsFragment.java +++ b/src/com/android/email/activity/setup/AccountCheckSettingsFragment.java @@ -417,6 +417,10 @@ public class AccountCheckSettingsFragment extends Fragment { EmailServiceProxy.VALIDATE_BUNDLE_ERROR_MESSAGE); return new MessagingException(resultCode, errorMessage); } + + // Save account capabilities + mAccount.mCapabilities = bundle.getInt( + EmailServiceProxy.SETTINGS_BUNDLE_CAPABILITIES, 0); } final EmailServiceInfo info; diff --git a/src/com/android/email/activity/setup/AccountSettingsFragment.java b/src/com/android/email/activity/setup/AccountSettingsFragment.java index 97d4f9ea8..26e476455 100644 --- a/src/com/android/email/activity/setup/AccountSettingsFragment.java +++ b/src/com/android/email/activity/setup/AccountSettingsFragment.java @@ -69,6 +69,7 @@ import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.EmailContent.AccountColumns; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.provider.Policy; +import com.android.emailcommon.service.EmailServiceProxy; import com.android.mail.preferences.AccountPreferences; import com.android.mail.preferences.FolderPreferences; import com.android.mail.preferences.FolderPreferences.NotificationLight; @@ -84,7 +85,9 @@ import com.android.mail.utils.LogUtils; import com.android.mail.utils.NotificationUtils; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -243,10 +246,7 @@ public class AccountSettingsFragment extends MailAccountPrefsFragment final CharSequence [] syncIntervals = savedInstanceState.getCharSequenceArray(SAVESTATE_SYNC_INTERVALS); mCheckFrequency = (ListPreference) findPreference(PREFERENCE_FREQUENCY); - if (mCheckFrequency != null) { - mCheckFrequency.setEntries(syncIntervalStrings); - mCheckFrequency.setEntryValues(syncIntervals); - } + fillCheckFrecuency(syncIntervalStrings, syncIntervals); } } @@ -382,16 +382,15 @@ public class AccountSettingsFragment extends MailAccountPrefsFragment final android.accounts.Account androidAcct = new android.accounts.Account( mAccount.mEmailAddress, mServiceInfo.accountType); if (Integer.parseInt(summary) == Account.CHECK_INTERVAL_NEVER) { - // Disable syncing from the account manager. Leave the current sync frequency - // in the database. + // Disable syncing from the account manager. ContentResolver.setSyncAutomatically(androidAcct, EmailContent.AUTHORITY, false); } else { // Enable syncing from the account manager. ContentResolver.setSyncAutomatically(androidAcct, EmailContent.AUTHORITY, true); - cv.put(AccountColumns.SYNC_INTERVAL, Integer.parseInt(summary)); } + cv.put(AccountColumns.SYNC_INTERVAL, Integer.parseInt(summary)); } } else if (key.equals(PREFERENCE_SYNC_WINDOW)) { final String summary = newValue.toString(); @@ -749,8 +748,7 @@ public class AccountSettingsFragment extends MailAccountPrefsFragment R.string.preferences_signature_summary_not_set); mCheckFrequency = (ListPreference) findPreference(PREFERENCE_FREQUENCY); - mCheckFrequency.setEntries(mServiceInfo.syncIntervalStrings); - mCheckFrequency.setEntryValues(mServiceInfo.syncIntervals); + fillCheckFrecuency(mServiceInfo.syncIntervalStrings, mServiceInfo.syncIntervals); if (mServiceInfo.syncContacts || mServiceInfo.syncCalendar) { // This account allows syncing of contacts and/or calendar, so we will always have // separate preferences to enable or disable syncing of email, contacts, and calendar. @@ -1182,4 +1180,28 @@ public class AccountSettingsFragment extends MailAccountPrefsFragment } mInboxLights.setOn(notificationLight.mOn); } + + private void fillCheckFrecuency(CharSequence[] labels, CharSequence[] values) { + if (mCheckFrequency == null) { + return; + } + + // Check push capability prior to include as an option + if (mAccount != null) { + boolean hasPushCapability = mAccount.hasCapability(EmailServiceProxy.CAPABILITY_PUSH); + List valuesList = new ArrayList<>(Arrays.asList(values)); + int checkIntervalPushPos = valuesList.indexOf( + String.valueOf(Account.CHECK_INTERVAL_PUSH)); + if (!hasPushCapability && checkIntervalPushPos != -1) { + List labelsList = new ArrayList<>(Arrays.asList(labels)); + labelsList.remove(checkIntervalPushPos); + valuesList.remove(checkIntervalPushPos); + labels = labelsList.toArray(new CharSequence[labelsList.size()]); + values = valuesList.toArray(new CharSequence[valuesList.size()]); + } + } + mCheckFrequency.setEntries(labels); + mCheckFrequency.setEntryValues(values); + mCheckFrequency.setDefaultValue(values); + } } diff --git a/src/com/android/email/activity/setup/AccountSetupFinal.java b/src/com/android/email/activity/setup/AccountSetupFinal.java index adaa32aa1..5386a7e4a 100644 --- a/src/com/android/email/activity/setup/AccountSetupFinal.java +++ b/src/com/android/email/activity/setup/AccountSetupFinal.java @@ -917,7 +917,7 @@ public class AccountSetupFinal extends AccountSetupActivity public void setDefaultsForProtocol(Account account) { final EmailServiceUtils.EmailServiceInfo info = mSetupData.getIncomingServiceInfo(this); if (info == null) return; - account.mSyncInterval = info.defaultSyncInterval; + account.setSyncInterval(info.defaultSyncInterval); account.mSyncLookback = info.defaultLookback; if (info.offerLocalDeletes) { account.setDeletePolicy(info.defaultLocalDeletes); diff --git a/src/com/android/email/activity/setup/AccountSetupOptionsFragment.java b/src/com/android/email/activity/setup/AccountSetupOptionsFragment.java index 9d048c119..287a0d323 100644 --- a/src/com/android/email/activity/setup/AccountSetupOptionsFragment.java +++ b/src/com/android/email/activity/setup/AccountSetupOptionsFragment.java @@ -29,8 +29,13 @@ import com.android.email.activity.UiUtilities; import com.android.email.service.EmailServiceUtils; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.Policy; +import com.android.emailcommon.service.EmailServiceProxy; import com.android.emailcommon.service.SyncWindow; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + public class AccountSetupOptionsFragment extends AccountSetupFragment { private Spinner mCheckFrequencyView; private Spinner mSyncWindowView; @@ -90,11 +95,24 @@ public class AccountSetupOptionsFragment extends AccountSetupFragment { final CharSequence[] frequencyEntries = serviceInfo.syncIntervalStrings; // Now create the array used by the sync interval Spinner - final SpinnerOption[] checkFrequencies = new SpinnerOption[frequencyEntries.length]; + int checkIntervalPushPos = -1; + SpinnerOption[] checkFrequencies = new SpinnerOption[frequencyEntries.length]; for (int i = 0; i < frequencyEntries.length; i++) { - checkFrequencies[i] = new SpinnerOption( - Integer.valueOf(frequencyValues[i].toString()), frequencyEntries[i].toString()); + Integer value = Integer.valueOf(frequencyValues[i].toString()); + if (value.intValue() == Account.CHECK_INTERVAL_PUSH) { + checkIntervalPushPos = i; + } + checkFrequencies[i] = new SpinnerOption(value, frequencyEntries[i].toString()); } + + // Ensure that push capability is supported by the server + boolean hasPushCapability = account.hasCapability(EmailServiceProxy.CAPABILITY_PUSH); + if (!hasPushCapability && checkIntervalPushPos != -1) { + List options = new ArrayList<>(Arrays.asList(checkFrequencies)); + options.remove(checkIntervalPushPos); + checkFrequencies = options.toArray(new SpinnerOption[options.size()]); + } + final ArrayAdapter checkFrequenciesAdapter = new ArrayAdapter<>(getActivity(), android.R.layout.simple_spinner_item, checkFrequencies);