diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 75fe254fb..552ea7bee 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -468,6 +468,17 @@ android:resource="@xml/syncadapter_pop3" /> + + + + + + + - + + android:name="android.accounts.AccountAuthenticator" /> - + - - - - - - - diff --git a/build/res/values/strings.xml b/build/res/values/strings.xml index dc60ac9c3..f0b3eed92 100644 --- a/build/res/values/strings.xml +++ b/build/res/values/strings.xml @@ -17,12 +17,15 @@ com.android.exchange - com.android.pop3 - com.android.imap + com.android.email + com.android.email + com.android.email com.android.email.EXCHANGE_INTENT com.android.email.ACCOUNT_MANAGER_ENTRY_INTENT com.android.email.provider + imap imap pop3 + eas application/email-ls diff --git a/build/res/xml/services.xml b/build/res/xml/services.xml index 9af2ae611..a1780e585 100644 --- a/build/res/xml/services.xml +++ b/build/res/xml/services.xml @@ -50,8 +50,8 @@ - diff --git a/res/drawable-mdpi/ic_exchange_minitab_selected.png b/res/drawable-mdpi/ic_exchange_minitab_selected.png deleted file mode 100644 index ef7e11657..000000000 Binary files a/res/drawable-mdpi/ic_exchange_minitab_selected.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_exchange_selected.png b/res/drawable-mdpi/ic_exchange_selected.png deleted file mode 100644 index 116340678..000000000 Binary files a/res/drawable-mdpi/ic_exchange_selected.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_folder_drafts_holo_light.png b/res/drawable-mdpi/ic_folder_drafts_holo_light.png deleted file mode 100644 index 6db6c812c..000000000 Binary files a/res/drawable-mdpi/ic_folder_drafts_holo_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_folder_inbox_holo_light.png b/res/drawable-mdpi/ic_folder_inbox_holo_light.png deleted file mode 100644 index 62f67d29b..000000000 Binary files a/res/drawable-mdpi/ic_folder_inbox_holo_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_folder_outbox_holo_light.png b/res/drawable-mdpi/ic_folder_outbox_holo_light.png deleted file mode 100644 index a7039e12e..000000000 Binary files a/res/drawable-mdpi/ic_folder_outbox_holo_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_folder_sent_holo_light.png b/res/drawable-mdpi/ic_folder_sent_holo_light.png deleted file mode 100644 index ca93d583b..000000000 Binary files a/res/drawable-mdpi/ic_folder_sent_holo_light.png and /dev/null differ diff --git a/res/drawable-mdpi/ic_notification_multiple_mail_holo_dark.png b/res/drawable-mdpi/ic_notification_multiple_mail_holo_dark.png deleted file mode 100644 index 8071c272d..000000000 Binary files a/res/drawable-mdpi/ic_notification_multiple_mail_holo_dark.png and /dev/null differ diff --git a/res/drawable-mdpi/stat_notify_email_generic.png b/res/drawable-mdpi/stat_notify_email_generic.png deleted file mode 100644 index e8ede3a2d..000000000 Binary files a/res/drawable-mdpi/stat_notify_email_generic.png and /dev/null differ diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 1feea6aff..728c00e99 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -24,6 +24,7 @@ + diff --git a/res/values/strings.xml b/res/values/strings.xml index 22db48abe..7dcab89e7 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1322,8 +1322,8 @@ as %s. No messages. - IMAP - POP3 + IMAP + POP3 Folder picker diff --git a/res/xml/authenticator_imap.xml b/res/xml/authenticator_imap.xml index a8129ae10..72bcf0431 100644 --- a/res/xml/authenticator_imap.xml +++ b/res/xml/authenticator_imap.xml @@ -24,6 +24,6 @@ android:accountType="@string/account_manager_type_imap" android:icon="@mipmap/ic_launcher_mail" android:smallIcon="@drawable/stat_notify_email_generic" - android:label="@string/imap2_name" + android:label="@string/imap_name" android:accountPreferences="@xml/account_preferences" /> diff --git a/res/xml/authenticator_legacy_imap2.xml b/res/xml/authenticator_legacy_imap.xml similarity index 90% rename from res/xml/authenticator_legacy_imap2.xml rename to res/xml/authenticator_legacy_imap.xml index 4cafcb93d..8182cef3c 100644 --- a/res/xml/authenticator_legacy_imap2.xml +++ b/res/xml/authenticator_legacy_imap.xml @@ -21,9 +21,9 @@ diff --git a/res/xml/syncadapter_imap.xml b/res/xml/syncadapter_legacy_imap.xml similarity index 93% rename from res/xml/syncadapter_imap.xml rename to res/xml/syncadapter_legacy_imap.xml index 1171a1f7d..6ad6ee140 100644 --- a/res/xml/syncadapter_imap.xml +++ b/res/xml/syncadapter_legacy_imap.xml @@ -22,6 +22,6 @@ diff --git a/src/com/android/email/activity/setup/AccountSetupType.java b/src/com/android/email/activity/setup/AccountSetupType.java index ff830945c..f118b8c14 100644 --- a/src/com/android/email/activity/setup/AccountSetupType.java +++ b/src/com/android/email/activity/setup/AccountSetupType.java @@ -80,7 +80,8 @@ public class AccountSetupType extends AccountSetupActivity implements OnClickLis for (EmailServiceInfo info: EmailServiceUtils.getServiceInfoList(this)) { if (EmailServiceUtils.isServiceAvailable(this, info.protocol)) { // If we're looking for a specific account type, reject others - if (accountType != null && !accountType.equals(info.accountType)) { + // Don't show types with "hide" set + if (info.hide || (accountType != null && !accountType.equals(info.accountType))) { continue; } LayoutInflater.from(this).inflate(R.layout.account_type, parent); diff --git a/src/com/android/email/mail/Store.java b/src/com/android/email/mail/Store.java index fcbf2833a..f23325745 100644 --- a/src/com/android/email/mail/Store.java +++ b/src/com/android/email/mail/Store.java @@ -21,6 +21,7 @@ import android.os.Bundle; import android.util.Log; import com.android.email.R; +import com.android.email.mail.store.ImapStore; import com.android.email.mail.store.Pop3Store; import com.android.email.mail.store.ServiceStore; import com.android.email.mail.transport.MailTransport; @@ -84,6 +85,7 @@ public abstract class Store { throws MessagingException { if (sStores.isEmpty()) { sStoreClasses.put(context.getString(R.string.protocol_pop3), Pop3Store.class); + sStoreClasses.put(context.getString(R.string.protocol_legacy_imap), ImapStore.class); } HostAuth hostAuth = account.getOrCreateHostAuthRecv(context); // An existing account might have been deleted diff --git a/src/com/android/email/mail/store/ImapConnection.java b/src/com/android/email/mail/store/ImapConnection.java new file mode 100644 index 000000000..68baa4fc3 --- /dev/null +++ b/src/com/android/email/mail/store/ImapConnection.java @@ -0,0 +1,519 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store; + +import android.text.TextUtils; +import android.util.Log; + +import com.android.email.mail.store.ImapStore.ImapException; +import com.android.email.mail.store.imap.ImapConstants; +import com.android.email.mail.store.imap.ImapList; +import com.android.email.mail.store.imap.ImapResponse; +import com.android.email.mail.store.imap.ImapResponseParser; +import com.android.email.mail.store.imap.ImapUtility; +import com.android.email.mail.transport.DiscourseLogger; +import com.android.email.mail.transport.MailTransport; +import com.android.email2.ui.MailActivityEmail; +import com.android.emailcommon.Logging; +import com.android.emailcommon.mail.AuthenticationFailedException; +import com.android.emailcommon.mail.CertificateValidationException; +import com.android.emailcommon.mail.MessagingException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.net.ssl.SSLException; + +/** + * A cacheable class that stores the details for a single IMAP connection. + */ +class ImapConnection { + // Always check in FALSE + private static final boolean DEBUG_FORCE_SEND_ID = false; + + /** ID capability per RFC 2971*/ + public static final int CAPABILITY_ID = 1 << 0; + /** NAMESPACE capability per RFC 2342 */ + public static final int CAPABILITY_NAMESPACE = 1 << 1; + /** STARTTLS capability per RFC 3501 */ + public static final int CAPABILITY_STARTTLS = 1 << 2; + /** UIDPLUS capability per RFC 4315 */ + public static final int CAPABILITY_UIDPLUS = 1 << 3; + + /** The capabilities supported; a set of CAPABILITY_* values. */ + private int mCapabilities; + private static final String IMAP_REDACTED_LOG = "[IMAP command redacted]"; + MailTransport mTransport; + private ImapResponseParser mParser; + private ImapStore mImapStore; + private String mUsername; + private String mLoginPhrase; + private String mIdPhrase = null; + /** # 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); + /** + * Next tag to use. All connections associated to the same ImapStore instance share the same + * counter to make tests simpler. + * (Some of the tests involve multiple connections but only have a single counter to track the + * tag.) + */ + private final AtomicInteger mNextCommandTag = new AtomicInteger(0); + + + // Keep others from instantiating directly + ImapConnection(ImapStore store, String username, String password) { + setStore(store, username, password); + } + + void setStore(ImapStore store, String username, String password) { + if (username != null && password != null) { + mUsername = username; + + // build the LOGIN string once (instead of over-and-over again.) + // apply the quoting here around the built-up password + mLoginPhrase = ImapConstants.LOGIN + " " + mUsername + " " + + ImapUtility.imapQuoted(password); + } + mImapStore = store; + } + void open() throws IOException, MessagingException { + if (mTransport != null && mTransport.isOpen()) { + return; + } + + try { + // copy configuration into a clean transport, if necessary + if (mTransport == null) { + mTransport = mImapStore.cloneTransport(); + } + + mTransport.open(); + mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT); + + createParser(); + + // BANNER + mParser.readResponse(); + + // CAPABILITY + ImapResponse capabilities = queryCapabilities(); + + boolean hasStartTlsCapability = + capabilities.contains(ImapConstants.STARTTLS); + + // TLS + ImapResponse newCapabilities = doStartTls(hasStartTlsCapability); + if (newCapabilities != null) { + capabilities = newCapabilities; + } + + // NOTE: An IMAP response MUST be processed before issuing any new IMAP + // requests. Subsequent requests may destroy previous response data. As + // such, we save away capability information here for future use. + setCapabilities(capabilities); + String capabilityString = capabilities.flatten(); + + // ID + doSendId(isCapable(CAPABILITY_ID), capabilityString); + + // LOGIN + doLogin(); + + // NAMESPACE (only valid in the Authenticated state) + doGetNamespace(isCapable(CAPABILITY_NAMESPACE)); + + // Gets the path separator from the server + doGetPathSeparator(); + + mImapStore.ensurePrefixIsValid(); + } catch (SSLException e) { + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, e.toString()); + } + throw new CertificateValidationException(e.getMessage(), e); + } catch (IOException ioe) { + // NOTE: Unlike similar code in POP3, I'm going to rethrow as-is. There is a lot + // of other code here that catches IOException and I don't want to break it. + // This catch is only here to enhance logging of connection-time issues. + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, ioe.toString()); + } + throw ioe; + } finally { + destroyResponses(); + } + } + + /** + * Closes the connection and releases all resources. This connection can not be used again + * until {@link #setStore(ImapStore, String, String)} is called. + */ + void close() { + if (mTransport != null) { + mTransport.close(); + mTransport = null; + } + destroyResponses(); + mParser = null; + mImapStore = null; + } + + /** + * Returns whether or not the specified capability is supported by the server. + */ + private boolean isCapable(int capability) { + return (mCapabilities & capability) != 0; + } + + /** + * Sets the capability flags according to the response provided by the server. + * Note: We only set the capability flags that we are interested in. There are many IMAP + * capabilities that we do not track. + */ + private void setCapabilities(ImapResponse capabilities) { + if (capabilities.contains(ImapConstants.ID)) { + mCapabilities |= CAPABILITY_ID; + } + if (capabilities.contains(ImapConstants.NAMESPACE)) { + mCapabilities |= CAPABILITY_NAMESPACE; + } + if (capabilities.contains(ImapConstants.UIDPLUS)) { + mCapabilities |= CAPABILITY_UIDPLUS; + } + if (capabilities.contains(ImapConstants.STARTTLS)) { + mCapabilities |= CAPABILITY_STARTTLS; + } + } + + /** + * Create an {@link ImapResponseParser} from {@code mTransport.getInputStream()} and + * set it to {@link #mParser}. + * + * If we already have an {@link ImapResponseParser}, we + * {@link #destroyResponses()} and throw it away. + */ + private void createParser() { + destroyResponses(); + mParser = new ImapResponseParser(mTransport.getInputStream(), mDiscourse); + } + + void destroyResponses() { + if (mParser != null) { + mParser.destroyResponses(); + } + } + + boolean isTransportOpenForTest() { + return mTransport != null ? mTransport.isOpen() : false; + } + + ImapResponse readResponse() throws IOException, MessagingException { + return mParser.readResponse(); + } + + /** + * Send a single command to the server. The command will be preceded by an IMAP command + * tag and followed by \r\n (caller need not supply them). + * + * @param command The command to send to the server + * @param sensitive If true, the command will not be logged + * @return Returns the command tag that was sent + */ + String sendCommand(String command, boolean sensitive) + throws MessagingException, IOException { + open(); + String tag = Integer.toString(mNextCommandTag.incrementAndGet()); + String commandToSend = tag + " " + command; + mTransport.writeLine(commandToSend, sensitive ? IMAP_REDACTED_LOG : null); + mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend); + return tag; + } + + + /** + * Send a single, complex command to the server. The command will be preceded by an IMAP + * command tag and followed by \r\n (caller need not supply them). After each piece of the + * command, a response will be read which MUST be a continuation request. + * + * @param commands An array of Strings comprising the command to be sent to the server + * @return Returns the command tag that was sent + */ + String sendComplexCommand(List commands, boolean sensitive) throws MessagingException, + IOException { + open(); + String tag = Integer.toString(mNextCommandTag.incrementAndGet()); + int len = commands.size(); + for (int i = 0; i < len; i++) { + String commandToSend = commands.get(i); + // The first part of the command gets the tag + if (i == 0) { + commandToSend = tag + " " + commandToSend; + } else { + // Otherwise, read the response from the previous part of the command + ImapResponse response = readResponse(); + // If it isn't a continuation request, that's an error + if (!response.isContinuationRequest()) { + throw new MessagingException("Expected continuation request"); + } + } + // Send the command + mTransport.writeLine(commandToSend, null); + mDiscourse.addSentCommand(sensitive ? IMAP_REDACTED_LOG : commandToSend); + } + return tag; + } + + List executeSimpleCommand(String command) throws IOException, + MessagingException { + return executeSimpleCommand(command, false); + } + + /** + * Read and return all of the responses from the most recent command sent to the server + * + * @return a list of ImapResponses + * @throws IOException + * @throws MessagingException + */ + List getCommandResponses() throws IOException, MessagingException { + ArrayList responses = new ArrayList(); + ImapResponse response; + do { + response = mParser.readResponse(); + responses.add(response); + } while (!response.isTagged()); + if (!response.isOk()) { + final String toString = response.toString(); + final String alert = response.getAlertTextOrEmpty().getString(); + destroyResponses(); + throw new ImapException(toString, alert); + } + return responses; + } + + /** + * Execute a simple command at the server, a simple command being one that is sent in a single + * line of text + * + * @param command the command to send to the server + * @param sensitive whether the command should be redacted in logs (used for login) + * @return a list of ImapResponses + * @throws IOException + * @throws MessagingException + */ + List executeSimpleCommand(String command, boolean sensitive) + throws IOException, MessagingException { + sendCommand(command, sensitive); + return getCommandResponses(); + } + + /** + * Execute a complex command at the server, a complex command being one that must be sent in + * multiple lines due to the use of string literals + * + * @param commands a list of strings that comprise the command to be sent to the server + * @param sensitive whether the command should be redacted in logs (used for login) + * @return a list of ImapResponses + * @throws IOException + * @throws MessagingException + */ + List executeComplexCommand(List commands, boolean sensitive) + throws IOException, MessagingException { + sendComplexCommand(commands, sensitive); + return getCommandResponses(); + } + + /** + * Query server for capabilities. + */ + private ImapResponse queryCapabilities() throws IOException, MessagingException { + ImapResponse capabilityResponse = null; + for (ImapResponse r : executeSimpleCommand(ImapConstants.CAPABILITY)) { + if (r.is(0, ImapConstants.CAPABILITY)) { + capabilityResponse = r; + break; + } + } + if (capabilityResponse == null) { + throw new MessagingException("Invalid CAPABILITY response received"); + } + return capabilityResponse; + } + + /** + * Sends client identification information to the IMAP server per RFC 2971. If + * the server does not support the ID command, this will perform no operation. + * + * Interoperability hack: Never send ID to *.secureserver.net, which sends back a + * malformed response that our parser can't deal with. + */ + private void doSendId(boolean hasIdCapability, String capabilities) + throws MessagingException { + if (!hasIdCapability) return; + + // Never send ID to *.secureserver.net + String host = mTransport.getHost(); + if (host.toLowerCase().endsWith(".secureserver.net")) return; + + // Assign user-agent string (for RFC2971 ID command) + String mUserAgent = + ImapStore.getImapId(mImapStore.getContext(), mUsername, host, capabilities); + + if (mUserAgent != null) { + mIdPhrase = ImapConstants.ID + " (" + mUserAgent + ")"; + } else if (DEBUG_FORCE_SEND_ID) { + mIdPhrase = ImapConstants.ID + " " + ImapConstants.NIL; + } + // else: mIdPhrase = null, no ID will be emitted + + // Send user-agent in an RFC2971 ID command + if (mIdPhrase != null) { + try { + executeSimpleCommand(mIdPhrase); + } catch (ImapException ie) { + // Log for debugging, but this is not a fatal problem. + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, ie.toString()); + } + } catch (IOException ioe) { + // Special case to handle malformed OK responses and ignore them. + // A true IOException will recur on the following login steps + // This can go away after the parser is fixed - see bug 2138981 + } + } + } + + /** + * Gets the user's Personal Namespace from the IMAP server per RFC 2342. If the user + * explicitly sets a namespace (using setup UI) or if the server does not support the + * namespace command, this will perform no operation. + */ + private void doGetNamespace(boolean hasNamespaceCapability) throws MessagingException { + // user did not specify a hard-coded prefix; try to get it from the server + if (hasNamespaceCapability && !mImapStore.isUserPrefixSet()) { + List responseList = Collections.emptyList(); + + try { + responseList = executeSimpleCommand(ImapConstants.NAMESPACE); + } catch (ImapException ie) { + // Log for debugging, but this is not a fatal problem. + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, ie.toString()); + } + } catch (IOException ioe) { + // Special case to handle malformed OK responses and ignore them. + } + + for (ImapResponse response: responseList) { + if (response.isDataResponse(0, ImapConstants.NAMESPACE)) { + ImapList namespaceList = response.getListOrEmpty(1); + ImapList namespace = namespaceList.getListOrEmpty(0); + String namespaceString = namespace.getStringOrEmpty(0).getString(); + if (!TextUtils.isEmpty(namespaceString)) { + mImapStore.setPathPrefix(ImapStore.decodeFolderName(namespaceString, null)); + mImapStore.setPathSeparator(namespace.getStringOrEmpty(1).getString()); + } + } + } + } + } + + /** + * Logs into the IMAP server + */ + private void doLogin() + throws IOException, MessagingException, AuthenticationFailedException { + try { + // TODO eventually we need to add additional authentication + // options such as SASL + executeSimpleCommand(mLoginPhrase, true); + } catch (ImapException ie) { + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, ie.toString()); + } + throw new AuthenticationFailedException(ie.getAlertText(), ie); + + } catch (MessagingException me) { + throw new AuthenticationFailedException(null, me); + } + } + + /** + * Gets the path separator per the LIST command in RFC 3501. If the path separator + * was obtained while obtaining the namespace or there is no prefix defined, this + * will perform no operation. + */ + private void doGetPathSeparator() throws MessagingException { + // user did not specify a hard-coded prefix; try to get it from the server + if (mImapStore.isUserPrefixSet()) { + List responseList = Collections.emptyList(); + + try { + responseList = executeSimpleCommand(ImapConstants.LIST + " \"\" \"\""); + } catch (ImapException ie) { + // Log for debugging, but this is not a fatal problem. + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, ie.toString()); + } + } catch (IOException ioe) { + // Special case to handle malformed OK responses and ignore them. + } + + for (ImapResponse response: responseList) { + if (response.isDataResponse(0, ImapConstants.LIST)) { + mImapStore.setPathSeparator(response.getStringOrEmpty(2).getString()); + } + } + } + } + + /** + * Starts a TLS session with the IMAP server per RFC 3501. If the user has not opted + * to use TLS or the server does not support the TLS capability, this will perform + * no operation. + */ + private ImapResponse doStartTls(boolean hasStartTlsCapability) + throws IOException, MessagingException { + if (mTransport.canTryTlsSecurity()) { + if (hasStartTlsCapability) { + // STARTTLS + executeSimpleCommand(ImapConstants.STARTTLS); + + mTransport.reopenTls(); + mTransport.setSoTimeout(MailTransport.SOCKET_READ_TIMEOUT); + createParser(); + // Per RFC requirement (3501-6.2.1) gather new capabilities + return(queryCapabilities()); + } else { + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, "TLS not supported but required"); + } + throw new MessagingException(MessagingException.TLS_REQUIRED); + } + } + return null; + } + + /** @see DiscourseLogger#logLastDiscourse() */ + void logLastDiscourse() { + mDiscourse.logLastDiscourse(); + } +} \ No newline at end of file diff --git a/src/com/android/email/mail/store/ImapFolder.java b/src/com/android/email/mail/store/ImapFolder.java new file mode 100644 index 000000000..e13366153 --- /dev/null +++ b/src/com/android/email/mail/store/ImapFolder.java @@ -0,0 +1,1134 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Base64DataException; +import android.util.Log; + +import com.android.email.mail.store.ImapStore.ImapException; +import com.android.email.mail.store.ImapStore.ImapMessage; +import com.android.email.mail.store.imap.ImapConstants; +import com.android.email.mail.store.imap.ImapElement; +import com.android.email.mail.store.imap.ImapList; +import com.android.email.mail.store.imap.ImapResponse; +import com.android.email.mail.store.imap.ImapString; +import com.android.email.mail.store.imap.ImapUtility; +import com.android.email2.ui.MailActivityEmail; +import com.android.emailcommon.Logging; +import com.android.emailcommon.internet.BinaryTempFileBody; +import com.android.emailcommon.internet.MimeBodyPart; +import com.android.emailcommon.internet.MimeHeader; +import com.android.emailcommon.internet.MimeMultipart; +import com.android.emailcommon.internet.MimeUtility; +import com.android.emailcommon.mail.AuthenticationFailedException; +import com.android.emailcommon.mail.Body; +import com.android.emailcommon.mail.FetchProfile; +import com.android.emailcommon.mail.Flag; +import com.android.emailcommon.mail.Folder; +import com.android.emailcommon.mail.Message; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Part; +import com.android.emailcommon.provider.Mailbox; +import com.android.emailcommon.service.SearchParams; +import com.android.emailcommon.utility.CountingOutputStream; +import com.android.emailcommon.utility.EOLConvertingOutputStream; +import com.android.emailcommon.utility.Utility; +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; + +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; + + private final ImapStore mStore; + private final String mName; + private int mMessageCount = -1; + private ImapConnection mConnection; + private OpenMode mMode; + private boolean mExists; + /** The local mailbox associated with this remote folder */ + Mailbox mMailbox; + /** A set of hashes that can be used to track dirtiness */ + Object mHash[]; + + /*package*/ ImapFolder(ImapStore store, String name) { + mStore = store; + mName = name; + } + + private void destroyResponses() { + if (mConnection != null) { + mConnection.destroyResponses(); + } + } + + @Override + public void open(OpenMode mode) + throws MessagingException { + try { + if (isOpen()) { + if (mMode == mode) { + // Make sure the connection is valid. + // If it's not we'll close it down and continue on to get a new one. + try { + mConnection.executeSimpleCommand(ImapConstants.NOOP); + return; + + } catch (IOException ioe) { + ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + } else { + // Return the connection to the pool, if exists. + close(false); + } + } + synchronized (this) { + mConnection = mStore.getConnection(); + } + // * FLAGS (\Answered \Flagged \Deleted \Seen \Draft NonJunk + // $MDNSent) + // * OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft + // NonJunk $MDNSent \*)] Flags permitted. + // * 23 EXISTS + // * 0 RECENT + // * OK [UIDVALIDITY 1125022061] UIDs valid + // * OK [UIDNEXT 57576] Predicted next UID + // 2 OK [READ-WRITE] Select completed. + try { + doSelect(); + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + } catch (AuthenticationFailedException e) { + // Don't cache this connection, so we're forced to try connecting/login again + mConnection = null; + close(false); + throw e; + } catch (MessagingException e) { + mExists = false; + close(false); + throw e; + } + } + + @Override + @VisibleForTesting + public boolean isOpen() { + return mExists && mConnection != null; + } + + @Override + public OpenMode getMode() { + return mMode; + } + + @Override + public void close(boolean expunge) { + // TODO implement expunge + mMessageCount = -1; + synchronized (this) { + mStore.poolConnection(mConnection); + mConnection = null; + } + } + + @Override + public String getName() { + return mName; + } + + @Override + public boolean exists() throws MessagingException { + if (mExists) { + return true; + } + /* + * This method needs to operate in the unselected mode as well as the selected mode + * so we must get the connection ourselves if it's not there. We are specifically + * not calling checkOpen() since we don't care if the folder is open. + */ + ImapConnection connection = null; + synchronized(this) { + if (mConnection == null) { + connection = mStore.getConnection(); + } else { + connection = mConnection; + } + } + try { + connection.executeSimpleCommand(String.format( + ImapConstants.STATUS + " \"%s\" (" + ImapConstants.UIDVALIDITY + ")", + ImapStore.encodeFolderName(mName, mStore.mPathPrefix))); + mExists = true; + return true; + + } catch (MessagingException me) { + // Treat IOERROR messaging exception as IOException + if (me.getExceptionType() == MessagingException.IOERROR) { + throw me; + } + return false; + + } catch (IOException ioe) { + throw ioExceptionHandler(connection, ioe); + + } finally { + connection.destroyResponses(); + if (mConnection == null) { + mStore.poolConnection(connection); + } + } + } + + // IMAP supports folder creation + @Override + public boolean canCreate(FolderType type) { + return true; + } + + @Override + public boolean create(FolderType type) throws MessagingException { + /* + * This method needs to operate in the unselected mode as well as the selected mode + * so we must get the connection ourselves if it's not there. We are specifically + * not calling checkOpen() since we don't care if the folder is open. + */ + ImapConnection connection = null; + synchronized(this) { + if (mConnection == null) { + connection = mStore.getConnection(); + } else { + connection = mConnection; + } + } + try { + connection.executeSimpleCommand(String.format(ImapConstants.CREATE + " \"%s\"", + ImapStore.encodeFolderName(mName, mStore.mPathPrefix))); + return true; + + } catch (MessagingException me) { + return false; + + } catch (IOException ioe) { + throw ioExceptionHandler(connection, ioe); + + } finally { + connection.destroyResponses(); + if (mConnection == null) { + mStore.poolConnection(connection); + } + } + } + + @Override + public void copyMessages(Message[] messages, Folder folder, + MessageUpdateCallbacks callbacks) throws MessagingException { + checkOpen(); + try { + List responseList = mConnection.executeSimpleCommand( + String.format(ImapConstants.UID_COPY + " %s \"%s\"", + ImapStore.joinMessageUids(messages), + ImapStore.encodeFolderName(folder.getName(), mStore.mPathPrefix))); + // Build a message map for faster UID matching + HashMap messageMap = new HashMap(); + boolean handledUidPlus = false; + for (Message m : messages) { + messageMap.put(m.getUid(), m); + } + // Process response to get the new UIDs + for (ImapResponse response : responseList) { + // All "BAD" responses are bad. Only "NO", tagged responses are bad. + if (response.isBad() || (response.isNo() && response.isTagged())) { + String responseText = response.getStatusResponseTextOrEmpty().getString(); + throw new MessagingException(responseText); + } + // Skip untagged responses; they're just status + if (!response.isTagged()) { + continue; + } + // No callback provided to report of UID changes; nothing more to do here + // NOTE: We check this here to catch any server errors + if (callbacks == null) { + continue; + } + ImapList copyResponse = response.getListOrEmpty(1); + String responseCode = copyResponse.getStringOrEmpty(0).getString(); + if (ImapConstants.COPYUID.equals(responseCode)) { + handledUidPlus = true; + String origIdSet = copyResponse.getStringOrEmpty(2).getString(); + String newIdSet = copyResponse.getStringOrEmpty(3).getString(); + String[] origIdArray = ImapUtility.getImapSequenceValues(origIdSet); + String[] newIdArray = ImapUtility.getImapSequenceValues(newIdSet); + // There has to be a 1:1 mapping between old and new IDs + if (origIdArray.length != newIdArray.length) { + throw new MessagingException("Set length mis-match; orig IDs \"" + + origIdSet + "\" new IDs \"" + newIdSet + "\""); + } + for (int i = 0; i < origIdArray.length; i++) { + final String id = origIdArray[i]; + final Message m = messageMap.get(id); + if (m != null) { + callbacks.onMessageUidChange(m, newIdArray[i]); + } + } + } + } + // If the server doesn't support UIDPLUS, try a different way to get the new UID(s) + if (callbacks != null && !handledUidPlus) { + ImapFolder newFolder = (ImapFolder)folder; + try { + // Temporarily select the destination folder + newFolder.open(OpenMode.READ_WRITE); + // Do the search(es) ... + for (Message m : messages) { + String searchString = "HEADER Message-Id \"" + m.getMessageId() + "\""; + String[] newIdArray = newFolder.searchForUids(searchString); + if (newIdArray.length == 1) { + callbacks.onMessageUidChange(m, newIdArray[0]); + } + } + } catch (MessagingException e) { + // Log, but, don't abort; failures here don't need to be propagated + Log.d(Logging.LOG_TAG, "Failed to find message", e); + } finally { + newFolder.close(false); + } + // Re-select the original folder + doSelect(); + } + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + } + + @Override + public int getMessageCount() { + return mMessageCount; + } + + @Override + public int getUnreadMessageCount() throws MessagingException { + checkOpen(); + try { + int unreadMessageCount = 0; + List responses = mConnection.executeSimpleCommand(String.format( + ImapConstants.STATUS + " \"%s\" (" + ImapConstants.UNSEEN + ")", + ImapStore.encodeFolderName(mName, mStore.mPathPrefix))); + // S: * STATUS mboxname (MESSAGES 231 UIDNEXT 44292) + for (ImapResponse response : responses) { + if (response.isDataResponse(0, ImapConstants.STATUS)) { + unreadMessageCount = response.getListOrEmpty(2) + .getKeyedStringOrEmpty(ImapConstants.UNSEEN).getNumberOrZero(); + } + } + return unreadMessageCount; + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + } + + @Override + public void delete(boolean recurse) { + throw new Error("ImapStore.delete() not yet implemented"); + } + + String[] getSearchUids(List responses) { + // S: * SEARCH 2 3 6 + final ArrayList uids = new ArrayList(); + for (ImapResponse response : responses) { + if (!response.isDataResponse(0, ImapConstants.SEARCH)) { + continue; + } + // Found SEARCH response data + for (int i = 1; i < response.size(); i++) { + ImapString s = response.getStringOrEmpty(i); + if (s.isString()) { + uids.add(s.getString()); + } + } + } + return uids.toArray(Utility.EMPTY_STRINGS); + } + + @VisibleForTesting + String[] searchForUids(String searchCriteria) throws MessagingException { + checkOpen(); + try { + try { + String command = ImapConstants.UID_SEARCH + " " + searchCriteria; + return getSearchUids(mConnection.executeSimpleCommand(command)); + } catch (ImapException e) { + Log.d(Logging.LOG_TAG, "ImapException in search: " + searchCriteria); + return Utility.EMPTY_STRINGS; // not found; + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } finally { + destroyResponses(); + } + } + + @Override + @VisibleForTesting + public Message getMessage(String uid) throws MessagingException { + checkOpen(); + + String[] uids = searchForUids(ImapConstants.UID + " " + uid); + for (int i = 0; i < uids.length; i++) { + if (uids[i].equals(uid)) { + return new ImapMessage(uid, this); + } + } + return null; + } + + @VisibleForTesting + protected static boolean isAsciiString(String str) { + int len = str.length(); + for (int i = 0; i < len; i++) { + char c = str.charAt(i); + if (c >= 128) return false; + } + return true; + } + + /** + * Retrieve messages based on search parameters. We search FROM, TO, CC, SUBJECT, and BODY + * We send: SEARCH OR FROM "foo" (OR TO "foo" (OR CC "foo" (OR SUBJECT "foo" BODY "foo"))), but + * with the additional CHARSET argument and sending "foo" as a literal (e.g. {3}foo} + */ + @Override + @VisibleForTesting + public Message[] getMessages(SearchParams params, MessageRetrievalListener listener) + throws MessagingException { + List commands = new ArrayList(); + String filter = params.mFilter; + // All servers MUST accept US-ASCII, so we'll send this as the CHARSET unless we're really + // dealing with a string that contains non-ascii characters + String charset = "US-ASCII"; + if (!isAsciiString(filter)) { + charset = "UTF-8"; + } + // This is the length of the string in octets (bytes), formatted as a string literal {n} + String octetLength = "{" + filter.getBytes().length + "}"; + // Break the command up into pieces ending with the string literal length + commands.add(ImapConstants.UID_SEARCH + " CHARSET " + charset + " OR FROM " + octetLength); + commands.add(filter + " (OR TO " + octetLength); + commands.add(filter + " (OR CC " + octetLength); + commands.add(filter + " (OR SUBJECT " + octetLength); + commands.add(filter + " BODY " + octetLength); + commands.add(filter + ")))"); + return getMessagesInternal(complexSearchForUids(commands), listener); + } + + /* package */ String[] complexSearchForUids(List commands) throws MessagingException { + checkOpen(); + try { + try { + return getSearchUids(mConnection.executeComplexCommand(commands, false)); + } catch (ImapException e) { + return Utility.EMPTY_STRINGS; // not found; + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } finally { + destroyResponses(); + } + } + + @Override + @VisibleForTesting + public Message[] getMessages(int start, int end, MessageRetrievalListener listener) + throws MessagingException { + if (start < 1 || end < 1 || end < start) { + throw new MessagingException(String.format("Invalid range: %d %d", start, end)); + } + return getMessagesInternal( + searchForUids(String.format("%d:%d NOT DELETED", start, end)), listener); + } + + @Override + @VisibleForTesting + public Message[] getMessages(String[] uids, MessageRetrievalListener listener) + throws MessagingException { + if (uids == null) { + uids = searchForUids("1:* NOT DELETED"); + } + return getMessagesInternal(uids, listener); + } + + public Message[] getMessagesInternal(String[] uids, MessageRetrievalListener listener) { + final ArrayList messages = new ArrayList(uids.length); + for (int i = 0; i < uids.length; i++) { + final String uid = uids[i]; + final ImapMessage message = new ImapMessage(uid, this); + messages.add(message); + if (listener != null) { + listener.messageRetrieved(message); + } + } + return messages.toArray(Message.EMPTY_ARRAY); + } + + @Override + public void fetch(Message[] messages, FetchProfile fp, MessageRetrievalListener listener) + throws MessagingException { + try { + fetchInternal(messages, fp, listener); + } catch (RuntimeException e) { // Probably a parser error. + Log.w(Logging.LOG_TAG, "Exception detected: " + e.getMessage()); + if (mConnection != null) { + mConnection.logLastDiscourse(); + } + throw e; + } + } + + public void fetchInternal(Message[] messages, FetchProfile fp, + MessageRetrievalListener listener) throws MessagingException { + if (messages.length == 0) { + return; + } + checkOpen(); + HashMap messageMap = new HashMap(); + for (Message m : messages) { + messageMap.put(m.getUid(), m); + } + + /* + * Figure out what command we are going to run: + * FLAGS - UID FETCH (FLAGS) + * ENVELOPE - UID FETCH (INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[ + * HEADER.FIELDS (date subject from content-type to cc)]) + * STRUCTURE - UID FETCH (BODYSTRUCTURE) + * BODY_SANE - UID FETCH (BODY.PEEK[]<0.N>) where N = max bytes returned + * BODY - UID FETCH (BODY.PEEK[]) + * Part - UID FETCH (BODY.PEEK[ID]) where ID = mime part ID + */ + + final LinkedHashSet fetchFields = new LinkedHashSet(); + + fetchFields.add(ImapConstants.UID); + if (fp.contains(FetchProfile.Item.FLAGS)) { + fetchFields.add(ImapConstants.FLAGS); + } + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + fetchFields.add(ImapConstants.INTERNALDATE); + fetchFields.add(ImapConstants.RFC822_SIZE); + fetchFields.add(ImapConstants.FETCH_FIELD_HEADERS); + } + if (fp.contains(FetchProfile.Item.STRUCTURE)) { + fetchFields.add(ImapConstants.BODYSTRUCTURE); + } + + if (fp.contains(FetchProfile.Item.BODY_SANE)) { + fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK_SANE); + } + if (fp.contains(FetchProfile.Item.BODY)) { + fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK); + } + + final Part fetchPart = fp.getFirstPart(); + if (fetchPart != null) { + String[] partIds = + fetchPart.getHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA); + if (partIds != null) { + fetchFields.add(ImapConstants.FETCH_FIELD_BODY_PEEK_BARE + + "[" + partIds[0] + "]"); + } + } + + try { + mConnection.sendCommand(String.format( + ImapConstants.UID_FETCH + " %s (%s)", ImapStore.joinMessageUids(messages), + Utility.combine(fetchFields.toArray(new String[fetchFields.size()]), ' ') + ), false); + ImapResponse response; + int messageNumber = 0; + do { + response = null; + try { + response = mConnection.readResponse(); + + if (!response.isDataResponse(1, ImapConstants.FETCH)) { + continue; // Ignore + } + final ImapList fetchList = response.getListOrEmpty(2); + final String uid = fetchList.getKeyedStringOrEmpty(ImapConstants.UID) + .getString(); + if (TextUtils.isEmpty(uid)) continue; + + ImapMessage message = (ImapMessage) messageMap.get(uid); + if (message == null) continue; + + if (fp.contains(FetchProfile.Item.FLAGS)) { + final ImapList flags = + fetchList.getKeyedListOrEmpty(ImapConstants.FLAGS); + for (int i = 0, count = flags.size(); i < count; i++) { + final ImapString flag = flags.getStringOrEmpty(i); + if (flag.is(ImapConstants.FLAG_DELETED)) { + message.setFlagInternal(Flag.DELETED, true); + } else if (flag.is(ImapConstants.FLAG_ANSWERED)) { + message.setFlagInternal(Flag.ANSWERED, true); + } else if (flag.is(ImapConstants.FLAG_SEEN)) { + message.setFlagInternal(Flag.SEEN, true); + } else if (flag.is(ImapConstants.FLAG_FLAGGED)) { + message.setFlagInternal(Flag.FLAGGED, true); + } + } + } + if (fp.contains(FetchProfile.Item.ENVELOPE)) { + final Date internalDate = fetchList.getKeyedStringOrEmpty( + ImapConstants.INTERNALDATE).getDateOrNull(); + final int size = fetchList.getKeyedStringOrEmpty( + ImapConstants.RFC822_SIZE).getNumberOrZero(); + final String header = fetchList.getKeyedStringOrEmpty( + ImapConstants.BODY_BRACKET_HEADER, true).getString(); + + message.setInternalDate(internalDate); + message.setSize(size); + message.parse(Utility.streamFromAsciiString(header)); + } + if (fp.contains(FetchProfile.Item.STRUCTURE)) { + ImapList bs = fetchList.getKeyedListOrEmpty( + ImapConstants.BODYSTRUCTURE); + if (!bs.isEmpty()) { + try { + parseBodyStructure(bs, message, ImapConstants.TEXT); + } catch (MessagingException e) { + if (Logging.LOGD) { + Log.v(Logging.LOG_TAG, "Error handling message", e); + } + message.setBody(null); + } + } + } + if (fp.contains(FetchProfile.Item.BODY) + || fp.contains(FetchProfile.Item.BODY_SANE)) { + // Body is keyed by "BODY[]...". + // Previously used "BODY[..." but this can be confused with "BODY[HEADER..." + // TODO Should we accept "RFC822" as well?? + ImapString body = fetchList.getKeyedStringOrEmpty("BODY[]", true); + InputStream bodyStream = body.getAsStream(); + message.parse(bodyStream); + } + if (fetchPart != null && fetchPart.getSize() > 0) { + InputStream bodyStream = + fetchList.getKeyedStringOrEmpty("BODY[", true).getAsStream(); + String contentTransferEncoding = fetchPart.getHeader( + MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)[0]; + + // TODO Don't create 2 temp files. + // decodeBody creates BinaryTempFileBody, but we could avoid this + // if we implement ImapStringBody. + // (We'll need to share a temp file. Protect it with a ref-count.) + fetchPart.setBody(decodeBody(bodyStream, contentTransferEncoding, + fetchPart.getSize(), listener)); + } + + if (listener != null) { + listener.messageRetrieved(message); + } + } finally { + destroyResponses(); + } + } while (!response.isTagged()); + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } + } + + /** + * Removes any content transfer encoding from the stream and returns a Body. + * This code is taken/condensed from MimeUtility.decodeBody + */ + private Body decodeBody(InputStream in, String contentTransferEncoding, int size, + MessageRetrievalListener listener) throws IOException { + // Get a properly wrapped input stream + in = MimeUtility.getInputStreamForContentTransferEncoding(in, contentTransferEncoding); + BinaryTempFileBody tempBody = new BinaryTempFileBody(); + OutputStream out = tempBody.getOutputStream(); + try { + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + int n = 0; + int count = 0; + while (-1 != (n = in.read(buffer))) { + out.write(buffer, 0, n); + count += n; + if (listener != null) { + listener.loadAttachmentProgress(count * 100 / size); + } + } + } catch (Base64DataException bde) { + String warning = "\n\n" + MailActivityEmail.getMessageDecodeErrorString(); + out.write(warning.getBytes()); + } finally { + out.close(); + } + return tempBody; + } + + @Override + public Flag[] getPermanentFlags() { + return PERMANENT_FLAGS; + } + + /** + * Handle any untagged responses that the caller doesn't care to handle themselves. + * @param responses + */ + private void handleUntaggedResponses(List responses) { + for (ImapResponse response : responses) { + handleUntaggedResponse(response); + } + } + + /** + * Handle an untagged response that the caller doesn't care to handle themselves. + * @param response + */ + private void handleUntaggedResponse(ImapResponse response) { + if (response.isDataResponse(1, ImapConstants.EXISTS)) { + mMessageCount = response.getStringOrEmpty(0).getNumberOrZero(); + } + } + + private static void parseBodyStructure(ImapList bs, Part part, String id) + throws MessagingException { + if (bs.getElementOrNone(0).isList()) { + /* + * This is a multipart/* + */ + MimeMultipart mp = new MimeMultipart(); + for (int i = 0, count = bs.size(); i < count; i++) { + ImapElement e = bs.getElementOrNone(i); + if (e.isList()) { + /* + * For each part in the message we're going to add a new BodyPart and parse + * into it. + */ + MimeBodyPart bp = new MimeBodyPart(); + if (id.equals(ImapConstants.TEXT)) { + parseBodyStructure(bs.getListOrEmpty(i), bp, Integer.toString(i + 1)); + + } else { + parseBodyStructure(bs.getListOrEmpty(i), bp, id + "." + (i + 1)); + } + mp.addBodyPart(bp); + + } else { + if (e.isString()) { + mp.setSubType(bs.getStringOrEmpty(i).getString().toLowerCase()); + } + break; // Ignore the rest of the list. + } + } + part.setBody(mp); + } else { + /* + * This is a body. We need to add as much information as we can find out about + * it to the Part. + */ + + /* + body type + body subtype + body parameter parenthesized list + body id + body description + body encoding + body size + */ + + final ImapString type = bs.getStringOrEmpty(0); + final ImapString subType = bs.getStringOrEmpty(1); + final String mimeType = + (type.getString() + "/" + subType.getString()).toLowerCase(); + + final ImapList bodyParams = bs.getListOrEmpty(2); + final ImapString cid = bs.getStringOrEmpty(3); + final ImapString encoding = bs.getStringOrEmpty(5); + final int size = bs.getStringOrEmpty(6).getNumberOrZero(); + + if (MimeUtility.mimeTypeMatches(mimeType, MimeUtility.MIME_TYPE_RFC822)) { + // A body type of type MESSAGE and subtype RFC822 + // contains, immediately after the basic fields, the + // envelope structure, body structure, and size in + // text lines of the encapsulated message. + // [MESSAGE, RFC822, [NAME, filename.eml], NIL, NIL, 7BIT, 5974, NIL, + // [INLINE, [FILENAME*0, Fwd: Xxx..., FILENAME*1, filename.eml]], NIL] + /* + * This will be caught by fetch and handled appropriately. + */ + throw new MessagingException("BODYSTRUCTURE " + MimeUtility.MIME_TYPE_RFC822 + + " not yet supported."); + } + + /* + * Set the content type with as much information as we know right now. + */ + final StringBuilder contentType = new StringBuilder(mimeType); + + /* + * If there are body params we might be able to get some more information out + * of them. + */ + for (int i = 1, count = bodyParams.size(); i < count; i += 2) { + + // TODO We need to convert " into %22, but + // because MimeUtility.getHeaderParameter doesn't recognize it, + // we can't fix it for now. + contentType.append(String.format(";\n %s=\"%s\"", + bodyParams.getStringOrEmpty(i - 1).getString(), + bodyParams.getStringOrEmpty(i).getString())); + } + + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType.toString()); + + // Extension items + final ImapList bodyDisposition; + + if (type.is(ImapConstants.TEXT) && bs.getElementOrNone(9).isList()) { + // If media-type is TEXT, 9th element might be: [body-fld-lines] := number + // So, if it's not a list, use 10th element. + // (Couldn't find evidence in the RFC if it's ALWAYS 10th element.) + bodyDisposition = bs.getListOrEmpty(9); + } else { + bodyDisposition = bs.getListOrEmpty(8); + } + + final StringBuilder contentDisposition = new StringBuilder(); + + if (bodyDisposition.size() > 0) { + final String bodyDisposition0Str = + bodyDisposition.getStringOrEmpty(0).getString().toLowerCase(); + if (!TextUtils.isEmpty(bodyDisposition0Str)) { + contentDisposition.append(bodyDisposition0Str); + } + + final ImapList bodyDispositionParams = bodyDisposition.getListOrEmpty(1); + if (!bodyDispositionParams.isEmpty()) { + /* + * If there is body disposition information we can pull some more + * information about the attachment out. + */ + for (int i = 1, count = bodyDispositionParams.size(); i < count; i += 2) { + + // TODO We need to convert " into %22. See above. + contentDisposition.append(String.format(";\n %s=\"%s\"", + bodyDispositionParams.getStringOrEmpty(i - 1) + .getString().toLowerCase(), + bodyDispositionParams.getStringOrEmpty(i).getString())); + } + } + } + + if ((size > 0) + && (MimeUtility.getHeaderParameter(contentDisposition.toString(), "size") + == null)) { + contentDisposition.append(String.format(";\n size=%d", size)); + } + + if (contentDisposition.length() > 0) { + /* + * Set the content disposition containing at least the size. Attachment + * handling code will use this down the road. + */ + part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, + contentDisposition.toString()); + } + + /* + * Set the Content-Transfer-Encoding header. Attachment code will use this + * to parse the body. + */ + if (!encoding.isEmpty()) { + part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, + encoding.getString()); + } + + /* + * Set the Content-ID header. + */ + if (!cid.isEmpty()) { + part.setHeader(MimeHeader.HEADER_CONTENT_ID, cid.getString()); + } + + if (size > 0) { + if (part instanceof ImapMessage) { + ((ImapMessage) part).setSize(size); + } else if (part instanceof MimeBodyPart) { + ((MimeBodyPart) part).setSize(size); + } else { + throw new MessagingException("Unknown part type " + part.toString()); + } + } + part.setHeader(MimeHeader.HEADER_ANDROID_ATTACHMENT_STORE_DATA, id); + } + + } + + /** + * Appends the given messages to the selected folder. This implementation also determines + * the new UID of the given message on the IMAP server and sets the Message's UID to the + * new server UID. + */ + @Override + public void appendMessages(Message[] messages) throws MessagingException { + checkOpen(); + try { + for (Message message : messages) { + // Create output count + CountingOutputStream out = new CountingOutputStream(); + EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(out); + message.writeTo(eolOut); + eolOut.flush(); + // Create flag list (most often this will be "\SEEN") + String flagList = ""; + Flag[] flags = message.getFlags(); + if (flags.length > 0) { + StringBuilder sb = new StringBuilder(); + for (int i = 0, count = flags.length; i < count; i++) { + Flag flag = flags[i]; + if (flag == Flag.SEEN) { + sb.append(" " + ImapConstants.FLAG_SEEN); + } else if (flag == Flag.FLAGGED) { + sb.append(" " + ImapConstants.FLAG_FLAGGED); + } + } + if (sb.length() > 0) { + flagList = sb.substring(1); + } + } + + mConnection.sendCommand( + String.format(ImapConstants.APPEND + " \"%s\" (%s) {%d}", + ImapStore.encodeFolderName(mName, mStore.mPathPrefix), + flagList, + out.getCount()), false); + ImapResponse response; + do { + response = mConnection.readResponse(); + if (response.isContinuationRequest()) { + eolOut = new EOLConvertingOutputStream( + mConnection.mTransport.getOutputStream()); + message.writeTo(eolOut); + eolOut.write('\r'); + eolOut.write('\n'); + eolOut.flush(); + } else if (!response.isTagged()) { + handleUntaggedResponse(response); + } + } while (!response.isTagged()); + + // TODO Why not check the response? + + /* + * Try to recover the UID of the message from an APPENDUID response. + * e.g. 11 OK [APPENDUID 2 238268] APPEND completed + */ + final ImapList appendList = response.getListOrEmpty(1); + if ((appendList.size() >= 3) && appendList.is(0, ImapConstants.APPENDUID)) { + String serverUid = appendList.getStringOrEmpty(2).getString(); + if (!TextUtils.isEmpty(serverUid)) { + message.setUid(serverUid); + continue; + } + } + + /* + * Try to find the UID of the message we just appended using the + * Message-ID header. If there are more than one response, take the + * last one, as it's most likely the newest (the one we just uploaded). + */ + String messageId = message.getMessageId(); + if (messageId == null || messageId.length() == 0) { + continue; + } + // Most servers don't care about parenthesis in the search query [and, some + // fail to work if they are used] + String[] uids = searchForUids(String.format("HEADER MESSAGE-ID %s", messageId)); + if (uids.length > 0) { + message.setUid(uids[0]); + } + // However, there's at least one server [AOL] that fails to work unless there + // are parenthesis, so, try this as a last resort + uids = searchForUids(String.format("(HEADER MESSAGE-ID %s)", messageId)); + if (uids.length > 0) { + message.setUid(uids[0]); + } + } + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + } + + @Override + public Message[] expunge() throws MessagingException { + checkOpen(); + try { + handleUntaggedResponses(mConnection.executeSimpleCommand(ImapConstants.EXPUNGE)); + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + return null; + } + + @Override + public void setFlags(Message[] messages, Flag[] flags, boolean value) + throws MessagingException { + checkOpen(); + + String allFlags = ""; + if (flags.length > 0) { + StringBuilder flagList = new StringBuilder(); + for (int i = 0, count = flags.length; i < count; i++) { + Flag flag = flags[i]; + if (flag == Flag.SEEN) { + flagList.append(" " + ImapConstants.FLAG_SEEN); + } else if (flag == Flag.DELETED) { + flagList.append(" " + ImapConstants.FLAG_DELETED); + } else if (flag == Flag.FLAGGED) { + flagList.append(" " + ImapConstants.FLAG_FLAGGED); + } else if (flag == Flag.ANSWERED) { + flagList.append(" " + ImapConstants.FLAG_ANSWERED); + } + } + allFlags = flagList.substring(1); + } + try { + mConnection.executeSimpleCommand(String.format( + ImapConstants.UID_STORE + " %s %s" + ImapConstants.FLAGS_SILENT + " (%s)", + ImapStore.joinMessageUids(messages), + value ? "+" : "-", + allFlags)); + + } catch (IOException ioe) { + throw ioExceptionHandler(mConnection, ioe); + } finally { + destroyResponses(); + } + } + + /** + * Persists this folder. We will always perform the proper database operation (e.g. + * 'save' or 'update'). As an optimization, if a folder has not been modified, no + * database operations are performed. + */ + void save(Context context) { + final Mailbox mailbox = mMailbox; + if (!mailbox.isSaved()) { + mailbox.save(context); + mHash = mailbox.getHashes(); + } else { + Object[] hash = mailbox.getHashes(); + if (!Arrays.equals(mHash, hash)) { + mailbox.update(context, mailbox.toContentValues()); + mHash = hash; // Save updated hash + } + } + } + + /** + * Selects the folder for use. Before performing any operations on this folder, it + * must be selected. + */ + private void doSelect() throws IOException, MessagingException { + List responses = mConnection.executeSimpleCommand( + String.format(ImapConstants.SELECT + " \"%s\"", + ImapStore.encodeFolderName(mName, mStore.mPathPrefix))); + + // Assume the folder is opened read-write; unless we are notified otherwise + mMode = OpenMode.READ_WRITE; + int messageCount = -1; + for (ImapResponse response : responses) { + if (response.isDataResponse(1, ImapConstants.EXISTS)) { + messageCount = response.getStringOrEmpty(0).getNumberOrZero(); + } else if (response.isOk()) { + final ImapString responseCode = response.getResponseCodeOrEmpty(); + if (responseCode.is(ImapConstants.READ_ONLY)) { + mMode = OpenMode.READ_ONLY; + } else if (responseCode.is(ImapConstants.READ_WRITE)) { + mMode = OpenMode.READ_WRITE; + } + } else if (response.isTagged()) { // Not OK + throw new MessagingException("Can't open mailbox: " + + response.getStatusResponseTextOrEmpty()); + } + } + if (messageCount == -1) { + throw new MessagingException("Did not find message count during select"); + } + mMessageCount = messageCount; + mExists = true; + } + + private void checkOpen() throws MessagingException { + if (!isOpen()) { + throw new MessagingException("Folder " + mName + " is not open."); + } + } + + private MessagingException ioExceptionHandler(ImapConnection connection, IOException ioe) { + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, "IO Exception detected: ", ioe); + } + connection.close(); + if (connection == mConnection) { + mConnection = null; // To prevent close() from returning the connection to the pool. + close(false); + } + return new MessagingException("IO Error", ioe); + } + + @Override + public boolean equals(Object o) { + if (o instanceof ImapFolder) { + return ((ImapFolder)o).mName.equals(mName); + } + return super.equals(o); + } + + @Override + public Message createMessage(String uid) { + return new ImapMessage(uid, this); + } +} diff --git a/src/com/android/email/mail/store/ImapStore.java b/src/com/android/email/mail/store/ImapStore.java new file mode 100644 index 000000000..3ac09bf33 --- /dev/null +++ b/src/com/android/email/mail/store/ImapStore.java @@ -0,0 +1,617 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store; + +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; + +import com.android.email.LegacyConversions; +import com.android.email.Preferences; +import com.android.email.R; +import com.android.email.mail.Store; +import com.android.email.mail.store.imap.ImapConstants; +import com.android.email.mail.store.imap.ImapResponse; +import com.android.email.mail.store.imap.ImapString; +import com.android.email.mail.transport.MailTransport; +import com.android.emailcommon.Logging; +import com.android.emailcommon.VendorPolicyLoader; +import com.android.emailcommon.internet.MimeMessage; +import com.android.emailcommon.mail.AuthenticationFailedException; +import com.android.emailcommon.mail.Flag; +import com.android.emailcommon.mail.Folder; +import com.android.emailcommon.mail.Message; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.provider.Account; +import com.android.emailcommon.provider.HostAuth; +import com.android.emailcommon.provider.Mailbox; +import com.android.emailcommon.service.EmailServiceProxy; +import com.android.emailcommon.utility.Utility; +import com.beetstra.jutf7.CharsetProvider; +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.regex.Pattern; + + +/** + *
+ * TODO Need to start keeping track of UIDVALIDITY
+ * TODO Need a default response handler for things like folder updates
+ * TODO In fetch(), if we need a ImapMessage and were given
+ *      something else we can try to do a pre-fetch first.
+ * TODO Collect ALERT messages and show them to users.
+ *
+ * ftp://ftp.isi.edu/in-notes/rfc2683.txt When a client asks for
+ * certain information in a FETCH command, the server may return the requested
+ * information in any order, not necessarily in the order that it was requested.
+ * Further, the server may return the information in separate FETCH responses
+ * and may also return information that was not explicitly requested (to reflect
+ * to the client changes in the state of the subject message).
+ * 
+ */ +public class ImapStore extends Store { + /** Charset used for converting folder names to and from UTF-7 as defined by RFC 3501. */ + private static final Charset MODIFIED_UTF_7_CHARSET = + new CharsetProvider().charsetForName("X-RFC-3501"); + + @VisibleForTesting static String sImapId = null; + @VisibleForTesting String mPathPrefix; + @VisibleForTesting String mPathSeparator; + + private final ConcurrentLinkedQueue mConnectionPool = + new ConcurrentLinkedQueue(); + + /** + * Static named constructor. + */ + public static Store newInstance(Account account, Context context) throws MessagingException { + return new ImapStore(context, account); + } + + /** + * Creates a new store for the given account. Always use + * {@link #newInstance(Account, Context)} to create an IMAP store. + */ + private ImapStore(Context context, Account account) throws MessagingException { + mContext = context; + mAccount = account; + + HostAuth recvAuth = account.getOrCreateHostAuthRecv(context); + if (recvAuth == null) { + throw new MessagingException("No HostAuth in ImapStore?"); + } + mTransport = new MailTransport(context, "IMAP", recvAuth); + + String[] userInfo = recvAuth.getLogin(); + if (userInfo != null) { + mUsername = userInfo[0]; + mPassword = userInfo[1]; + } else { + mUsername = null; + mPassword = null; + } + mPathPrefix = recvAuth.mDomain; + } + + @VisibleForTesting + Collection getConnectionPoolForTest() { + return mConnectionPool; + } + + /** + * For testing only. Injects a different root transport (it will be copied using + * newInstanceWithConfiguration() each time IMAP sets up a new channel). The transport + * should already be set up and ready to use. Do not use for real code. + * @param testTransport The Transport to inject and use for all future communication. + */ + @VisibleForTesting + void setTransportForTest(MailTransport testTransport) { + mTransport = testTransport; + } + + /** + * Return, or create and return, an string suitable for use in an IMAP ID message. + * This is constructed similarly to the way the browser sets up its user-agent strings. + * See RFC 2971 for more details. The output of this command will be a series of key-value + * pairs delimited by spaces (there is no point in returning a structured result because + * this will be sent as-is to the IMAP server). No tokens, parenthesis or "ID" are included, + * because some connections may append additional values. + * + * The following IMAP ID keys may be included: + * name Android package name of the program + * os "android" + * os-version "version; model; build-id" + * vendor Vendor of the client/server + * x-android-device-model Model (only revealed if release build) + * x-android-net-operator Mobile network operator (if known) + * AGUID A device+account UID + * + * In addition, a vendor policy .apk can append key/value pairs. + * + * @param userName the username of the account + * @param host the host (server) of the account + * @param capabilities a list of the capabilities from the server + * @return a String for use in an IMAP ID message. + */ + public static String getImapId(Context context, String userName, String host, + String capabilities) { + // The first section is global to all IMAP connections, and generates the fixed + // values in any IMAP ID message + synchronized (ImapStore.class) { + if (sImapId == null) { + TelephonyManager tm = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + String networkOperator = tm.getNetworkOperatorName(); + if (networkOperator == null) networkOperator = ""; + + sImapId = makeCommonImapId(context.getPackageName(), Build.VERSION.RELEASE, + Build.VERSION.CODENAME, Build.MODEL, Build.ID, Build.MANUFACTURER, + networkOperator); + } + } + + // This section is per Store, and adds in a dynamic elements like UID's. + // We don't cache the result of this work, because the caller does anyway. + StringBuilder id = new StringBuilder(sImapId); + + // Optionally add any vendor-supplied id keys + String vendorId = VendorPolicyLoader.getInstance(context).getImapIdValues(userName, host, + capabilities); + if (vendorId != null) { + id.append(' '); + id.append(vendorId); + } + + // Generate a UID that mixes a "stable" device UID with the email address + try { + String devUID = Preferences.getPreferences(context).getDeviceUID(); + MessageDigest messageDigest; + messageDigest = MessageDigest.getInstance("SHA-1"); + messageDigest.update(userName.getBytes()); + messageDigest.update(devUID.getBytes()); + byte[] uid = messageDigest.digest(); + String hexUid = Base64.encodeToString(uid, Base64.NO_WRAP); + id.append(" \"AGUID\" \""); + id.append(hexUid); + id.append('\"'); + } catch (NoSuchAlgorithmException e) { + Log.d(Logging.LOG_TAG, "couldn't obtain SHA-1 hash for device UID"); + } + return id.toString(); + } + + /** + * Helper function that actually builds the static part of the IMAP ID string. This is + * separated from getImapId for testability. There is no escaping or encoding in IMAP ID so + * any rogue chars must be filtered here. + * + * @param packageName context.getPackageName() + * @param version Build.VERSION.RELEASE + * @param codeName Build.VERSION.CODENAME + * @param model Build.MODEL + * @param id Build.ID + * @param vendor Build.MANUFACTURER + * @param networkOperator TelephonyManager.getNetworkOperatorName() + * @return the static (never changes) portion of the IMAP ID + */ + @VisibleForTesting + static String makeCommonImapId(String packageName, String version, + String codeName, String model, String id, String vendor, String networkOperator) { + + // Before building up IMAP ID string, pre-filter the input strings for "legal" chars + // This is using a fairly arbitrary char set intended to pass through most reasonable + // version, model, and vendor strings: a-z A-Z 0-9 - _ + = ; : . , / + // The most important thing is *not* to pass parens, quotes, or CRLF, which would break + // the format of the IMAP ID list. + Pattern p = Pattern.compile("[^a-zA-Z0-9-_\\+=;:\\.,/ ]"); + packageName = p.matcher(packageName).replaceAll(""); + version = p.matcher(version).replaceAll(""); + codeName = p.matcher(codeName).replaceAll(""); + model = p.matcher(model).replaceAll(""); + id = p.matcher(id).replaceAll(""); + vendor = p.matcher(vendor).replaceAll(""); + networkOperator = p.matcher(networkOperator).replaceAll(""); + + // "name" "com.android.email" + StringBuffer sb = new StringBuffer("\"name\" \""); + sb.append(packageName); + sb.append("\""); + + // "os" "android" + sb.append(" \"os\" \"android\""); + + // "os-version" "version; build-id" + sb.append(" \"os-version\" \""); + if (version.length() > 0) { + sb.append(version); + } else { + // default to "1.0" + sb.append("1.0"); + } + // add the build ID or build # + if (id.length() > 0) { + sb.append("; "); + sb.append(id); + } + sb.append("\""); + + // "vendor" "the vendor" + if (vendor.length() > 0) { + sb.append(" \"vendor\" \""); + sb.append(vendor); + sb.append("\""); + } + + // "x-android-device-model" the device model (on release builds only) + if ("REL".equals(codeName)) { + if (model.length() > 0) { + sb.append(" \"x-android-device-model\" \""); + sb.append(model); + sb.append("\""); + } + } + + // "x-android-mobile-net-operator" "name of network operator" + if (networkOperator.length() > 0) { + sb.append(" \"x-android-mobile-net-operator\" \""); + sb.append(networkOperator); + sb.append("\""); + } + + return sb.toString(); + } + + + @Override + public Folder getFolder(String name) { + return new ImapFolder(this, name); + } + + /** + * Creates a mailbox hierarchy out of the flat data provided by the server. + */ + @VisibleForTesting + static void createHierarchy(HashMap mailboxes) { + Set pathnames = mailboxes.keySet(); + for (String path : pathnames) { + final ImapFolder folder = mailboxes.get(path); + final Mailbox mailbox = folder.mMailbox; + int delimiterIdx = mailbox.mServerId.lastIndexOf(mailbox.mDelimiter); + long parentKey = Mailbox.NO_MAILBOX; + if (delimiterIdx != -1) { + String parentPath = path.substring(0, delimiterIdx); + final ImapFolder parentFolder = mailboxes.get(parentPath); + final Mailbox parentMailbox = (parentFolder == null) ? null : parentFolder.mMailbox; + if (parentMailbox != null) { + parentKey = parentMailbox.mId; + parentMailbox.mFlags + |= (Mailbox.FLAG_HAS_CHILDREN | Mailbox.FLAG_CHILDREN_VISIBLE); + } + } + mailbox.mParentKey = parentKey; + } + } + + /** + * Creates a {@link Folder} and associated {@link Mailbox}. If the folder does not already + * exist in the local database, a new row will immediately be created in the mailbox table. + * Otherwise, the existing row will be used. Any changes to existing rows, will not be stored + * to the database immediately. + * @param accountId The ID of the account the mailbox is to be associated with + * @param mailboxPath The path of the mailbox to add + * @param delimiter A path delimiter. May be {@code null} if there is no delimiter. + * @param selectable If {@code true}, the mailbox can be selected and used to store messages. + */ + private ImapFolder addMailbox(Context context, long accountId, String mailboxPath, + char delimiter, boolean selectable) { + ImapFolder folder = (ImapFolder) getFolder(mailboxPath); + Mailbox mailbox = Mailbox.getMailboxForPath(context, accountId, mailboxPath); + if (mailbox.isSaved()) { + // existing mailbox + // mailbox retrieved from database; save hash _before_ updating fields + folder.mHash = mailbox.getHashes(); + } + updateMailbox(mailbox, accountId, mailboxPath, delimiter, selectable, + LegacyConversions.inferMailboxTypeFromName(context, mailboxPath)); + if (folder.mHash == null) { + // new mailbox + // save hash after updating. allows tracking changes if the mailbox is saved + // outside of #saveMailboxList() + folder.mHash = mailbox.getHashes(); + // We must save this here to make sure we have a valid ID for later + mailbox.save(mContext); + } + folder.mMailbox = mailbox; + return folder; + } + + /** + * Persists the folders in the given list. + */ + private static void saveMailboxList(Context context, HashMap folderMap) { + for (ImapFolder imapFolder : folderMap.values()) { + imapFolder.save(context); + } + } + + @Override + public Folder[] updateFolders() throws MessagingException { + ImapConnection connection = getConnection(); + try { + HashMap mailboxes = new HashMap(); + // Establish a connection to the IMAP server; if necessary + // This ensures a valid prefix if the prefix is automatically set by the server + connection.executeSimpleCommand(ImapConstants.NOOP); + String imapCommand = ImapConstants.LIST + " \"\" \"*\""; + if (mPathPrefix != null) { + imapCommand = ImapConstants.LIST + " \"\" \"" + mPathPrefix + "*\""; + } + List responses = connection.executeSimpleCommand(imapCommand); + for (ImapResponse response : responses) { + // S: * LIST (\Noselect) "/" ~/Mail/foo + if (response.isDataResponse(0, ImapConstants.LIST)) { + // Get folder name. + ImapString encodedFolder = response.getStringOrEmpty(3); + if (encodedFolder.isEmpty()) continue; + + String folderName = decodeFolderName(encodedFolder.getString(), mPathPrefix); + if (ImapConstants.INBOX.equalsIgnoreCase(folderName)) continue; + + // Parse attributes. + boolean selectable = + !response.getListOrEmpty(1).contains(ImapConstants.FLAG_NO_SELECT); + String delimiter = response.getStringOrEmpty(2).getString(); + char delimiterChar = '\0'; + if (!TextUtils.isEmpty(delimiter)) { + delimiterChar = delimiter.charAt(0); + } + ImapFolder folder = + addMailbox(mContext, mAccount.mId, folderName, delimiterChar, selectable); + mailboxes.put(folderName, folder); + } + } + String inboxName = mContext.getString(R.string.mailbox_name_display_inbox); + Folder newFolder = + addMailbox(mContext, mAccount.mId, inboxName, '\0', true /*selectable*/); + mailboxes.put(ImapConstants.INBOX, (ImapFolder)newFolder); + createHierarchy(mailboxes); + saveMailboxList(mContext, mailboxes); + return mailboxes.values().toArray(new Folder[] {}); + } catch (IOException ioe) { + connection.close(); + throw new MessagingException("Unable to get folder list.", ioe); + } catch (AuthenticationFailedException afe) { + // We do NOT want this connection pooled, or we will continue to send NOOP and SELECT + // commands to the server + connection.destroyResponses(); + connection = null; + throw afe; + } finally { + if (connection != null) { + poolConnection(connection); + } + } + } + + @Override + public Bundle checkSettings() throws MessagingException { + int result = MessagingException.NO_ERROR; + Bundle bundle = new Bundle(); + ImapConnection connection = new ImapConnection(this, mUsername, mPassword); + try { + connection.open(); + connection.close(); + } catch (IOException ioe) { + bundle.putString(EmailServiceProxy.VALIDATE_BUNDLE_ERROR_MESSAGE, ioe.getMessage()); + result = MessagingException.IOERROR; + } finally { + connection.destroyResponses(); + } + bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, result); + return bundle; + } + + /** + * Returns whether or not the prefix has been set by the user. This can be determined by + * the fact that the prefix is set, but, the path separator is not set. + */ + boolean isUserPrefixSet() { + return TextUtils.isEmpty(mPathSeparator) && !TextUtils.isEmpty(mPathPrefix); + } + + /** Sets the path separator */ + void setPathSeparator(String pathSeparator) { + mPathSeparator = pathSeparator; + } + + /** Sets the prefix */ + void setPathPrefix(String pathPrefix) { + mPathPrefix = pathPrefix; + } + + /** Gets the context for this store */ + Context getContext() { + return mContext; + } + + /** Returns a clone of the transport associated with this store. */ + MailTransport cloneTransport() { + return mTransport.clone(); + } + + /** + * Fixes the path prefix, if necessary. The path prefix must always end with the + * path separator. + */ + void ensurePrefixIsValid() { + // Make sure the path prefix ends with the path separator + if (!TextUtils.isEmpty(mPathPrefix) && !TextUtils.isEmpty(mPathSeparator)) { + if (!mPathPrefix.endsWith(mPathSeparator)) { + mPathPrefix = mPathPrefix + mPathSeparator; + } + } + } + + /** + * Gets a connection if one is available from the pool, or creates a new one if not. + */ + ImapConnection getConnection() { + ImapConnection connection = null; + while ((connection = mConnectionPool.poll()) != null) { + try { + connection.setStore(this, mUsername, mPassword); + connection.executeSimpleCommand(ImapConstants.NOOP); + break; + } catch (MessagingException e) { + // Fall through + } catch (IOException e) { + // Fall through + } + connection.close(); + connection = null; + } + if (connection == null) { + connection = new ImapConnection(this, mUsername, mPassword); + } + return connection; + } + + /** + * Save a {@link ImapConnection} in the pool for reuse. Any responses associated with the + * connection are destroyed before adding the connection to the pool. + */ + void poolConnection(ImapConnection connection) { + if (connection != null) { + connection.destroyResponses(); + mConnectionPool.add(connection); + } + } + + /** + * Prepends the folder name with the given prefix and UTF-7 encodes it. + */ + static String encodeFolderName(String name, String prefix) { + // do NOT add the prefix to the special name "INBOX" + if (ImapConstants.INBOX.equalsIgnoreCase(name)) return name; + + // Prepend prefix + if (prefix != null) { + name = prefix + name; + } + + // TODO bypass the conversion if name doesn't have special char. + ByteBuffer bb = MODIFIED_UTF_7_CHARSET.encode(name); + byte[] b = new byte[bb.limit()]; + bb.get(b); + + return Utility.fromAscii(b); + } + + /** + * UTF-7 decodes the folder name and removes the given path prefix. + */ + static String decodeFolderName(String name, String prefix) { + // TODO bypass the conversion if name doesn't have special char. + String folder; + folder = MODIFIED_UTF_7_CHARSET.decode(ByteBuffer.wrap(Utility.toAscii(name))).toString(); + if ((prefix != null) && folder.startsWith(prefix)) { + folder = folder.substring(prefix.length()); + } + return folder; + } + + /** + * Returns UIDs of Messages joined with "," as the separator. + */ + static String joinMessageUids(Message[] messages) { + StringBuilder sb = new StringBuilder(); + boolean notFirst = false; + for (Message m : messages) { + if (notFirst) { + sb.append(','); + } + sb.append(m.getUid()); + notFirst = true; + } + return sb.toString(); + } + + static class ImapMessage extends MimeMessage { + ImapMessage(String uid, ImapFolder folder) { + mUid = uid; + mFolder = folder; + } + + public void setSize(int size) { + mSize = size; + } + + @Override + public void parse(InputStream in) throws IOException, MessagingException { + super.parse(in); + } + + public void setFlagInternal(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + } + + @Override + public void setFlag(Flag flag, boolean set) throws MessagingException { + super.setFlag(flag, set); + mFolder.setFlags(new Message[] { this }, new Flag[] { flag }, set); + } + } + + static class ImapException extends MessagingException { + private static final long serialVersionUID = 1L; + + String mAlertText; + + public ImapException(String message, String alertText, Throwable throwable) { + super(message, throwable); + mAlertText = alertText; + } + + public ImapException(String message, String alertText) { + super(message); + mAlertText = alertText; + } + + public String getAlertText() { + return mAlertText; + } + + public void setAlertText(String alertText) { + mAlertText = alertText; + } + } +} diff --git a/src/com/android/email/mail/store/imap/ImapConstants.java b/src/com/android/email/mail/store/imap/ImapConstants.java new file mode 100644 index 000000000..eee2ac44e --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapConstants.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +import com.android.email.mail.Store; + +public final class ImapConstants { + private ImapConstants() {} + + public static final String FETCH_FIELD_BODY_PEEK_BARE = "BODY.PEEK"; + public static final String FETCH_FIELD_BODY_PEEK = FETCH_FIELD_BODY_PEEK_BARE + "[]"; + public static final String FETCH_FIELD_BODY_PEEK_SANE + = String.format("BODY.PEEK[]<0.%d>", Store.FETCH_BODY_SANE_SUGGESTED_SIZE); + public static final String FETCH_FIELD_HEADERS = + "BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc message-id)]"; + + public static final String ALERT = "ALERT"; + public static final String APPEND = "APPEND"; + public static final String BAD = "BAD"; + public static final String BADCHARSET = "BADCHARSET"; + public static final String BODY = "BODY"; + public static final String BODY_BRACKET_HEADER = "BODY[HEADER"; + public static final String BODYSTRUCTURE = "BODYSTRUCTURE"; + public static final String BYE = "BYE"; + public static final String CAPABILITY = "CAPABILITY"; + public static final String CHECK = "CHECK"; + public static final String CLOSE = "CLOSE"; + public static final String COPY = "COPY"; + public static final String COPYUID = "COPYUID"; + public static final String CREATE = "CREATE"; + public static final String DELETE = "DELETE"; + public static final String EXAMINE = "EXAMINE"; + public static final String EXISTS = "EXISTS"; + public static final String EXPUNGE = "EXPUNGE"; + public static final String FETCH = "FETCH"; + public static final String FLAG_ANSWERED = "\\ANSWERED"; + public static final String FLAG_DELETED = "\\DELETED"; + public static final String FLAG_FLAGGED = "\\FLAGGED"; + public static final String FLAG_NO_SELECT = "\\NOSELECT"; + public static final String FLAG_SEEN = "\\SEEN"; + public static final String FLAGS = "FLAGS"; + public static final String FLAGS_SILENT = "FLAGS.SILENT"; + public static final String ID = "ID"; + public static final String INBOX = "INBOX"; + public static final String INTERNALDATE = "INTERNALDATE"; + public static final String LIST = "LIST"; + public static final String LOGIN = "LOGIN"; + public static final String LOGOUT = "LOGOUT"; + public static final String LSUB = "LSUB"; + public static final String NAMESPACE = "NAMESPACE"; + public static final String NO = "NO"; + public static final String NOOP = "NOOP"; + public static final String OK = "OK"; + public static final String PARSE = "PARSE"; + public static final String PERMANENTFLAGS = "PERMANENTFLAGS"; + 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 RENAME = "RENAME"; + public static final String RFC822_SIZE = "RFC822.SIZE"; + public static final String SEARCH = "SEARCH"; + public static final String SELECT = "SELECT"; + public static final String STARTTLS = "STARTTLS"; + public static final String STATUS = "STATUS"; + public static final String STORE = "STORE"; + public static final String SUBSCRIBE = "SUBSCRIBE"; + public static final String TEXT = "TEXT"; + public static final String TRYCREATE = "TRYCREATE"; + public static final String UID = "UID"; + public static final String UID_COPY = "UID COPY"; + public static final String UID_FETCH = "UID FETCH"; + public static final String UID_SEARCH = "UID SEARCH"; + public static final String UID_STORE = "UID STORE"; + public static final String UIDNEXT = "UIDNEXT"; + public static final String UIDPLUS = "UIDPLUS"; + public static final String UIDVALIDITY = "UIDVALIDITY"; + public static final String UNSEEN = "UNSEEN"; + public static final String UNSUBSCRIBE = "UNSUBSCRIBE"; + public static final String APPENDUID = "APPENDUID"; + public static final String NIL = "NIL"; +} diff --git a/src/com/android/email/mail/store/imap/ImapElement.java b/src/com/android/email/mail/store/imap/ImapElement.java new file mode 100644 index 000000000..80bb6cd99 --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapElement.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +/** + * Class representing "element"s in IMAP responses. + * + *

Class hierarchy: + *

+ * ImapElement
+ *   |
+ *   |-- ImapElement.NONE (for 'index out of range')
+ *   |
+ *   |-- ImapList (isList() == true)
+ *   |   |
+ *   |   |-- ImapList.EMPTY
+ *   |   |
+ *   |   --- ImapResponse
+ *   |
+ *   --- ImapString (isString() == true)
+ *       |
+ *       |-- ImapString.EMPTY
+ *       |
+ *       |-- ImapSimpleString
+ *       |
+ *       |-- ImapMemoryLiteral
+ *       |
+ *       --- ImapTempFileLiteral
+ * 
+ */ +public abstract class ImapElement { + /** + * An element that is returned by {@link ImapList#getElementOrNone} to indicate an index + * is out of range. + */ + public static final ImapElement NONE = new ImapElement() { + @Override public void destroy() { + // Don't call super.destroy(). + // It's a shared object. We don't want the mDestroyed to be set on this. + } + + @Override public boolean isList() { + return false; + } + + @Override public boolean isString() { + return false; + } + + @Override public String toString() { + return "[NO ELEMENT]"; + } + + @Override + public boolean equalsForTest(ImapElement that) { + return super.equalsForTest(that); + } + }; + + private boolean mDestroyed = false; + + public abstract boolean isList(); + + public abstract boolean isString(); + + protected boolean isDestroyed() { + return mDestroyed; + } + + /** + * Clean up the resources used by the instance. + * It's for removing a temp file used by {@link ImapTempFileLiteral}. + */ + public void destroy() { + mDestroyed = true; + } + + /** + * Throws {@link RuntimeException} if it's already destroyed. + */ + protected final void checkNotDestroyed() { + if (mDestroyed) { + throw new RuntimeException("Already destroyed"); + } + } + + /** + * Return a string that represents this object; it's purely for the debug purpose. Don't + * mistake it for {@link ImapString#getString}. + * + * Abstract to force subclasses to implement it. + */ + @Override + public abstract String toString(); + + /** + * The equals implementation that is intended to be used only for unit testing. + * (Because it may be heavy and has a special sense of "equal" for testing.) + */ + public boolean equalsForTest(ImapElement that) { + if (that == null) { + return false; + } + return this.getClass() == that.getClass(); // Has to be the same class. + } +} diff --git a/src/com/android/email/mail/store/imap/ImapList.java b/src/com/android/email/mail/store/imap/ImapList.java new file mode 100644 index 000000000..e28355989 --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapList.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +import java.util.ArrayList; + +/** + * Class represents an IMAP list. + */ +public class ImapList extends ImapElement { + /** + * {@link ImapList} representing an empty list. + */ + public static final ImapList EMPTY = new ImapList() { + @Override public void destroy() { + // Don't call super.destroy(). + // It's a shared object. We don't want the mDestroyed to be set on this. + } + + @Override void add(ImapElement e) { + throw new RuntimeException(); + } + }; + + private ArrayList mList = new ArrayList(); + + /* package */ void add(ImapElement e) { + if (e == null) { + throw new RuntimeException("Can't add null"); + } + mList.add(e); + } + + @Override + public final boolean isString() { + return false; + } + + @Override + public final boolean isList() { + return true; + } + + public final int size() { + return mList.size(); + } + + public final boolean isEmpty() { + return size() == 0; + } + + /** + * Return true if the element at {@code index} exists, is string, and equals to {@code s}. + * (case insensitive) + */ + public final boolean is(int index, String s) { + return is(index, s, false); + } + + /** + * Same as {@link #is(int, String)}, but does the prefix match if {@code prefixMatch}. + */ + public final boolean is(int index, String s, boolean prefixMatch) { + if (!prefixMatch) { + return getStringOrEmpty(index).is(s); + } else { + return getStringOrEmpty(index).startsWith(s); + } + } + + /** + * Return the element at {@code index}. + * If {@code index} is out of range, returns {@link ImapElement#NONE}. + */ + public final ImapElement getElementOrNone(int index) { + return (index >= mList.size()) ? ImapElement.NONE : mList.get(index); + } + + /** + * Return the element at {@code index} if it's a list. + * If {@code index} is out of range or not a list, returns {@link ImapList#EMPTY}. + */ + public final ImapList getListOrEmpty(int index) { + ImapElement el = getElementOrNone(index); + return el.isList() ? (ImapList) el : EMPTY; + } + + /** + * Return the element at {@code index} if it's a string. + * If {@code index} is out of range or not a string, returns {@link ImapString#EMPTY}. + */ + public final ImapString getStringOrEmpty(int index) { + ImapElement el = getElementOrNone(index); + return el.isString() ? (ImapString) el : ImapString.EMPTY; + } + + /** + * Return an element keyed by {@code key}. Return null if not found. {@code key} has to be + * at an even index. + */ + /* package */ final ImapElement getKeyedElementOrNull(String key, boolean prefixMatch) { + for (int i = 1; i < size(); i += 2) { + if (is(i-1, key, prefixMatch)) { + return mList.get(i); + } + } + return null; + } + + /** + * Return an {@link ImapList} keyed by {@code key}. + * Return {@link ImapList#EMPTY} if not found. + */ + public final ImapList getKeyedListOrEmpty(String key) { + return getKeyedListOrEmpty(key, false); + } + + /** + * Return an {@link ImapList} keyed by {@code key}. + * Return {@link ImapList#EMPTY} if not found. + */ + public final ImapList getKeyedListOrEmpty(String key, boolean prefixMatch) { + ImapElement e = getKeyedElementOrNull(key, prefixMatch); + return (e != null) ? ((ImapList) e) : ImapList.EMPTY; + } + + /** + * Return an {@link ImapString} keyed by {@code key}. + * Return {@link ImapString#EMPTY} if not found. + */ + public final ImapString getKeyedStringOrEmpty(String key) { + return getKeyedStringOrEmpty(key, false); + } + + /** + * Return an {@link ImapString} keyed by {@code key}. + * Return {@link ImapString#EMPTY} if not found. + */ + public final ImapString getKeyedStringOrEmpty(String key, boolean prefixMatch) { + ImapElement e = getKeyedElementOrNull(key, prefixMatch); + return (e != null) ? ((ImapString) e) : ImapString.EMPTY; + } + + /** + * Return true if it contains {@code s}. + */ + public final boolean contains(String s) { + for (int i = 0; i < size(); i++) { + if (getStringOrEmpty(i).is(s)) { + return true; + } + } + return false; + } + + @Override + public void destroy() { + if (mList != null) { + for (ImapElement e : mList) { + e.destroy(); + } + mList = null; + } + super.destroy(); + } + + @Override + public String toString() { + return mList.toString(); + } + + /** + * Return the text representations of the contents concatenated with ",". + */ + public final String flatten() { + return flatten(new StringBuilder()).toString(); + } + + /** + * Returns text representations (i.e. getString()) of contents joined together with + * "," as the separator. + * + * Only used for building the capability string passed to vendor policies. + * + * We can't use toString(), because it's for debugging (meaning the format may change any time), + * and it won't expand literals. + */ + private final StringBuilder flatten(StringBuilder sb) { + sb.append('['); + for (int i = 0; i < mList.size(); i++) { + if (i > 0) { + sb.append(','); + } + final ImapElement e = getElementOrNone(i); + if (e.isList()) { + getListOrEmpty(i).flatten(sb); + } else if (e.isString()) { + sb.append(getStringOrEmpty(i).getString()); + } + } + sb.append(']'); + return sb; + } + + @Override + public boolean equalsForTest(ImapElement that) { + if (!super.equalsForTest(that)) { + return false; + } + ImapList thatList = (ImapList) that; + if (size() != thatList.size()) { + return false; + } + for (int i = 0; i < size(); i++) { + if (!mList.get(i).equalsForTest(thatList.getElementOrNone(i))) { + return false; + } + } + return true; + } +} diff --git a/src/com/android/email/mail/store/imap/ImapMemoryLiteral.java b/src/com/android/email/mail/store/imap/ImapMemoryLiteral.java new file mode 100644 index 000000000..ea62d52d1 --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapMemoryLiteral.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +import com.android.email.FixedLengthInputStream; +import com.android.emailcommon.Logging; +import com.android.emailcommon.utility.Utility; + +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Subclass of {@link ImapString} used for literals backed by an in-memory byte array. + */ +public class ImapMemoryLiteral extends ImapString { + private byte[] mData; + + /* package */ ImapMemoryLiteral(FixedLengthInputStream in) throws IOException { + // We could use ByteArrayOutputStream and IOUtils.copy, but it'd perform an unnecessary + // copy.... + mData = new byte[in.getLength()]; + int pos = 0; + while (pos < mData.length) { + int read = in.read(mData, pos, mData.length - pos); + if (read < 0) { + break; + } + pos += read; + } + if (pos != mData.length) { + Log.w(Logging.LOG_TAG, ""); + } + } + + @Override + public void destroy() { + mData = null; + super.destroy(); + } + + @Override + public String getString() { + return Utility.fromAscii(mData); + } + + @Override + public InputStream getAsStream() { + return new ByteArrayInputStream(mData); + } + + @Override + public String toString() { + return String.format("{%d byte literal(memory)}", mData.length); + } +} diff --git a/src/com/android/email/mail/store/imap/ImapResponse.java b/src/com/android/email/mail/store/imap/ImapResponse.java new file mode 100644 index 000000000..05bf594e6 --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapResponse.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + + +/** + * Class represents an IMAP response. + */ +public class ImapResponse extends ImapList { + private final String mTag; + private final boolean mIsContinuationRequest; + + /* package */ ImapResponse(String tag, boolean isContinuationRequest) { + mTag = tag; + mIsContinuationRequest = isContinuationRequest; + } + + /* package */ static boolean isStatusResponse(String symbol) { + return ImapConstants.OK.equalsIgnoreCase(symbol) + || ImapConstants.NO.equalsIgnoreCase(symbol) + || ImapConstants.BAD.equalsIgnoreCase(symbol) + || ImapConstants.PREAUTH.equalsIgnoreCase(symbol) + || ImapConstants.BYE.equalsIgnoreCase(symbol); + } + + /** + * @return whether it's a tagged response. + */ + public boolean isTagged() { + return mTag != null; + } + + /** + * @return whether it's a continuation request. + */ + public boolean isContinuationRequest() { + return mIsContinuationRequest; + } + + public boolean isStatusResponse() { + return isStatusResponse(getStringOrEmpty(0).getString()); + } + + /** + * @return whether it's an OK response. + */ + public boolean isOk() { + return is(0, ImapConstants.OK); + } + + /** + * @return whether it's an BAD response. + */ + public boolean isBad() { + return is(0, ImapConstants.BAD); + } + + /** + * @return whether it's an NO response. + */ + public boolean isNo() { + return is(0, ImapConstants.NO); + } + + /** + * @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" + * @param responseType e.g. "FETCH" + */ + public final boolean isDataResponse(int index, String responseType) { + return !isTagged() && getStringOrEmpty(index).is(responseType); + } + + /** + * @return Response code (RFC 3501 7.1) if it's a status response. + * + * e.g. "ALERT" for "* OK [ALERT] System shutdown in 10 minutes" + */ + public ImapString getResponseCodeOrEmpty() { + if (!isStatusResponse()) { + return ImapString.EMPTY; // Not a status response. + } + return getListOrEmpty(1).getStringOrEmpty(0); + } + + /** + * @return Alert message it it has ALERT response code. + * + * e.g. "System shutdown in 10 minutes" for "* OK [ALERT] System shutdown in 10 minutes" + */ + public ImapString getAlertTextOrEmpty() { + if (!getResponseCodeOrEmpty().is(ImapConstants.ALERT)) { + return ImapString.EMPTY; // Not an ALERT + } + // The 3rd element contains all the rest of line. + return getStringOrEmpty(2); + } + + /** + * @return Response text in a status response. + */ + public ImapString getStatusResponseTextOrEmpty() { + if (!isStatusResponse()) { + return ImapString.EMPTY; + } + return getStringOrEmpty(getElementOrNone(1).isList() ? 2 : 1); + } + + @Override + public String toString() { + String tag = mTag; + if (isContinuationRequest()) { + tag = "+"; + } + return "#" + tag + "# " + super.toString(); + } + + @Override + public boolean equalsForTest(ImapElement that) { + if (!super.equalsForTest(that)) { + return false; + } + final ImapResponse thatResponse = (ImapResponse) that; + if (mTag == null) { + if (thatResponse.mTag != null) { + return false; + } + } else { + if (!mTag.equals(thatResponse.mTag)) { + return false; + } + } + if (mIsContinuationRequest != thatResponse.mIsContinuationRequest) { + return false; + } + return true; + } +} diff --git a/src/com/android/email/mail/store/imap/ImapResponseParser.java b/src/com/android/email/mail/store/imap/ImapResponseParser.java new file mode 100644 index 000000000..078cf9f76 --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapResponseParser.java @@ -0,0 +1,450 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +import android.text.TextUtils; +import android.util.Log; + +import com.android.email.FixedLengthInputStream; +import com.android.email.PeekableInputStream; +import com.android.email.mail.transport.DiscourseLogger; +import com.android.email2.ui.MailActivityEmail; +import com.android.emailcommon.Logging; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.utility.LoggingInputStream; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; + +/** + * IMAP response parser. + */ +public class ImapResponseParser { + private static final boolean DEBUG_LOG_RAW_STREAM = false; // DO NOT RELEASE AS 'TRUE' + + /** + * Literal larger than this will be stored in temp file. + */ + public static final int LITERAL_KEEP_IN_MEMORY_THRESHOLD = 2 * 1024 * 1024; + + /** Input stream */ + private final PeekableInputStream mIn; + + /** + * To log network activities when the parser crashes. + * + *

We log all bytes received from the server, except for the part sent as literals. + */ + private final DiscourseLogger mDiscourseLogger; + + private final int mLiteralKeepInMemoryThreshold; + + /** StringBuilder used by readUntil() */ + private final StringBuilder mBufferReadUntil = new StringBuilder(); + + /** StringBuilder used by parseBareString() */ + private final StringBuilder mParseBareString = new StringBuilder(); + + /** + * We store all {@link ImapResponse} in it. {@link #destroyResponses()} must be called from + * time to time to destroy them and clear it. + */ + private final ArrayList mResponsesToDestroy = new ArrayList(); + + /** + * Exception thrown when we receive BYE. It derives from IOException, so it'll be treated + * in the same way EOF does. + */ + public static class ByeException extends IOException { + public static final String MESSAGE = "Received BYE"; + public ByeException() { + super(MESSAGE); + } + } + + /** + * Public constructor for normal use. + */ + public ImapResponseParser(InputStream in, DiscourseLogger discourseLogger) { + this(in, discourseLogger, LITERAL_KEEP_IN_MEMORY_THRESHOLD); + } + + /** + * Constructor for testing to override the literal size threshold. + */ + /* package for test */ ImapResponseParser(InputStream in, DiscourseLogger discourseLogger, + int literalKeepInMemoryThreshold) { + if (DEBUG_LOG_RAW_STREAM && MailActivityEmail.DEBUG) { + in = new LoggingInputStream(in); + } + mIn = new PeekableInputStream(in); + mDiscourseLogger = discourseLogger; + mLiteralKeepInMemoryThreshold = literalKeepInMemoryThreshold; + } + + private static IOException newEOSException() { + final String message = "End of stream reached"; + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, message); + } + return new IOException(message); + } + + /** + * Peek next one byte. + * + * Throws IOException() if reaches EOF. As long as logical response lines end with \r\n, + * we shouldn't see EOF during parsing. + */ + private int peek() throws IOException { + final int next = mIn.peek(); + if (next == -1) { + throw newEOSException(); + } + return next; + } + + /** + * Read and return one byte from {@link #mIn}, and put it in {@link #mDiscourseLogger}. + * + * Throws IOException() if reaches EOF. As long as logical response lines end with \r\n, + * we shouldn't see EOF during parsing. + */ + private int readByte() throws IOException { + int next = mIn.read(); + if (next == -1) { + throw newEOSException(); + } + mDiscourseLogger.addReceivedByte(next); + return next; + } + + /** + * Destroy all the {@link ImapResponse}s stored in the internal storage and clear it. + * + * @see #readResponse() + */ + public void destroyResponses() { + for (ImapResponse r : mResponsesToDestroy) { + r.destroy(); + } + mResponsesToDestroy.clear(); + } + + /** + * Reads the next response available on the stream and returns an + * {@link ImapResponse} object that represents it. + * + *

When this method successfully returns an {@link ImapResponse}, the {@link ImapResponse} + * is stored in the internal storage. When the {@link ImapResponse} is no longer used + * {@link #destroyResponses} should be called to destroy all the responses in the array. + * + * @return the parsed {@link ImapResponse} object. + * @exception ByeException when detects BYE. + */ + public ImapResponse readResponse() throws IOException, MessagingException { + ImapResponse response = null; + try { + response = parseResponse(); + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, "<<< " + response.toString()); + } + + } catch (RuntimeException e) { + // Parser crash -- log network activities. + onParseError(e); + throw e; + } catch (IOException e) { + // Network error, or received an unexpected char. + onParseError(e); + throw e; + } + + // Handle this outside of try-catch. We don't have to dump protocol log when getting BYE. + if (response.is(0, ImapConstants.BYE)) { + Log.w(Logging.LOG_TAG, ByeException.MESSAGE); + response.destroy(); + throw new ByeException(); + } + mResponsesToDestroy.add(response); + return response; + } + + private void onParseError(Exception e) { + // Read a few more bytes, so that the log will contain some more context, even if the parser + // crashes in the middle of a response. + // This also makes sure the byte in question will be logged, no matter where it crashes. + // e.g. when parseAtom() peeks and finds at an unexpected char, it throws an exception + // before actually reading it. + // However, we don't want to read too much, because then it may get into an email message. + try { + for (int i = 0; i < 4; i++) { + int b = readByte(); + if (b == -1 || b == '\n') { + break; + } + } + } catch (IOException ignore) { + } + Log.w(Logging.LOG_TAG, "Exception detected: " + e.getMessage()); + mDiscourseLogger.logLastDiscourse(); + } + + /** + * Read next byte from stream and throw it away. If the byte is different from {@code expected} + * throw {@link MessagingException}. + */ + /* package for test */ void expect(char expected) throws IOException { + final int next = readByte(); + if (expected != next) { + throw new IOException(String.format("Expected %04x (%c) but got %04x (%c)", + (int) expected, expected, next, (char) next)); + } + } + + /** + * Read bytes until we find {@code end}, and return all as string. + * The {@code end} will be read (rather than peeked) and won't be included in the result. + */ + /* package for test */ String readUntil(char end) throws IOException { + mBufferReadUntil.setLength(0); + for (;;) { + final int ch = readByte(); + if (ch != end) { + mBufferReadUntil.append((char) ch); + } else { + return mBufferReadUntil.toString(); + } + } + } + + /** + * Read all bytes until \r\n. + */ + /* package */ String readUntilEol() throws IOException { + String ret = readUntil('\r'); + expect('\n'); // TODO Should this really be error? + return ret; + } + + /** + * Parse and return the response line. + */ + private ImapResponse parseResponse() throws IOException, MessagingException { + // We need to destroy the response if we get an exception. + // So, we first store the response that's being built in responseToDestroy, until it's + // completely built, at which point we copy it into responseToReturn and null out + // responseToDestroyt. + // If responseToDestroy is not null in finally, we destroy it because that means + // we got an exception somewhere. + ImapResponse responseToDestroy = null; + final ImapResponse responseToReturn; + + try { + final int ch = peek(); + if (ch == '+') { // Continuation request + readByte(); // skip + + expect(' '); + responseToDestroy = new ImapResponse(null, true); + + // If it's continuation request, we don't really care what's in it. + responseToDestroy.add(new ImapSimpleString(readUntilEol())); + + // Response has successfully been built. Let's return it. + responseToReturn = responseToDestroy; + responseToDestroy = null; + } else { + // Status response or response data + final String tag; + if (ch == '*') { + tag = null; + readByte(); // skip * + expect(' '); + } else { + tag = readUntil(' '); + } + responseToDestroy = new ImapResponse(tag, false); + + final ImapString firstString = parseBareString(); + responseToDestroy.add(firstString); + + // parseBareString won't eat a space after the string, so we need to skip it, + // if exists. + // If the next char is not ' ', it should be EOL. + if (peek() == ' ') { + readByte(); // skip ' ' + + if (responseToDestroy.isStatusResponse()) { // It's a status response + + // Is there a response code? + final int next = peek(); + if (next == '[') { + responseToDestroy.add(parseList('[', ']')); + if (peek() == ' ') { // Skip following space + readByte(); + } + } + + String rest = readUntilEol(); + if (!TextUtils.isEmpty(rest)) { + // The rest is free-form text. + responseToDestroy.add(new ImapSimpleString(rest)); + } + } else { // It's a response data. + parseElements(responseToDestroy, '\0'); + } + } else { + expect('\r'); + expect('\n'); + } + + // Response has successfully been built. Let's return it. + responseToReturn = responseToDestroy; + responseToDestroy = null; + } + } finally { + if (responseToDestroy != null) { + // We get an exception. + responseToDestroy.destroy(); + } + } + + return responseToReturn; + } + + private ImapElement parseElement() throws IOException, MessagingException { + final int next = peek(); + switch (next) { + case '(': + return parseList('(', ')'); + case '[': + return parseList('[', ']'); + case '"': + readByte(); // Skip " + return new ImapSimpleString(readUntil('"')); + case '{': + return parseLiteral(); + case '\r': // CR + readByte(); // Consume \r + expect('\n'); // Should be followed by LF. + return null; + case '\n': // LF // There shouldn't be a bare LF, but just in case. + readByte(); // Consume \n + return null; + default: + return parseBareString(); + } + } + + /** + * Parses an atom. + * + * Special case: If an atom contains '[', everything until the next ']' will be considered + * a part of the atom. + * (e.g. "BODY[HEADER.FIELDS ("DATE" ...)]" will become a single ImapString) + * + * If the value is "NIL", returns an empty string. + */ + private ImapString parseBareString() throws IOException, MessagingException { + mParseBareString.setLength(0); + for (;;) { + final int ch = peek(); + + // TODO Can we clean this up? (This condition is from the old parser.) + if (ch == '(' || ch == ')' || ch == '{' || ch == ' ' || + // ']' is not part of atom (it's in resp-specials) + ch == ']' || + // docs claim that flags are \ atom but atom isn't supposed to + // contain + // * and some flags contain * + // ch == '%' || ch == '*' || + ch == '%' || + // TODO probably should not allow \ and should recognize + // it as a flag instead + // ch == '"' || ch == '\' || + ch == '"' || (0x00 <= ch && ch <= 0x1f) || ch == 0x7f) { + if (mParseBareString.length() == 0) { + throw new MessagingException("Expected string, none found."); + } + String s = mParseBareString.toString(); + + // NIL will be always converted into the empty string. + if (ImapConstants.NIL.equalsIgnoreCase(s)) { + return ImapString.EMPTY; + } + return new ImapSimpleString(s); + } else if (ch == '[') { + // Eat all until next ']' + mParseBareString.append((char) readByte()); + mParseBareString.append(readUntil(']')); + mParseBareString.append(']'); // readUntil won't include the end char. + } else { + mParseBareString.append((char) readByte()); + } + } + } + + private void parseElements(ImapList list, char end) + throws IOException, MessagingException { + for (;;) { + for (;;) { + final int next = peek(); + if (next == end) { + return; + } + if (next != ' ') { + break; + } + // Skip space + readByte(); + } + final ImapElement el = parseElement(); + if (el == null) { // EOL + return; + } + list.add(el); + } + } + + private ImapList parseList(char opening, char closing) + throws IOException, MessagingException { + expect(opening); + final ImapList list = new ImapList(); + parseElements(list, closing); + expect(closing); + return list; + } + + private ImapString parseLiteral() throws IOException, MessagingException { + expect('{'); + final int size; + try { + size = Integer.parseInt(readUntil('}')); + } catch (NumberFormatException nfe) { + throw new MessagingException("Invalid length in literal"); + } + expect('\r'); + expect('\n'); + FixedLengthInputStream in = new FixedLengthInputStream(mIn, size); + if (size > mLiteralKeepInMemoryThreshold) { + return new ImapTempFileLiteral(in); + } else { + return new ImapMemoryLiteral(in); + } + } +} diff --git a/src/com/android/email/mail/store/imap/ImapSimpleString.java b/src/com/android/email/mail/store/imap/ImapSimpleString.java new file mode 100644 index 000000000..190c5237f --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapSimpleString.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +import com.android.emailcommon.utility.Utility; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +/** + * Subclass of {@link ImapString} used for non literals. + */ +public class ImapSimpleString extends ImapString { + private String mString; + + /* package */ ImapSimpleString(String string) { + mString = (string != null) ? string : ""; + } + + @Override + public void destroy() { + mString = null; + super.destroy(); + } + + @Override + public String getString() { + return mString; + } + + @Override + public InputStream getAsStream() { + return new ByteArrayInputStream(Utility.toAscii(mString)); + } + + @Override + public String toString() { + // Purposefully not return just mString, in order to prevent using it instead of getString. + return "\"" + mString + "\""; + } +} diff --git a/src/com/android/email/mail/store/imap/ImapString.java b/src/com/android/email/mail/store/imap/ImapString.java new file mode 100644 index 000000000..b0ee99d84 --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapString.java @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +import com.android.emailcommon.Logging; + +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** + * Class represents an IMAP "element" that is not a list. + * + * An atom, quoted string, literal, are all represented by this. Values like OK, STATUS are too. + * Also, this class class may contain more arbitrary value like "BODY[HEADER.FIELDS ("DATE")]". + * See {@link ImapResponseParser}. + */ +public abstract class ImapString extends ImapElement { + private static final byte[] EMPTY_BYTES = new byte[0]; + + public static final ImapString EMPTY = new ImapString() { + @Override public void destroy() { + // Don't call super.destroy(). + // It's a shared object. We don't want the mDestroyed to be set on this. + } + + @Override public String getString() { + return ""; + } + + @Override public InputStream getAsStream() { + return new ByteArrayInputStream(EMPTY_BYTES); + } + + @Override public String toString() { + return ""; + } + }; + + // This is used only for parsing IMAP's FETCH ENVELOPE command, in which + // en_US-like date format is used like "01-Jan-2009 11:20:39 -0800", so this should be + // handled by Locale.US + private final static SimpleDateFormat DATE_TIME_FORMAT = + new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss Z", Locale.US); + + private boolean mIsInteger; + private int mParsedInteger; + private Date mParsedDate; + + @Override + public final boolean isList() { + return false; + } + + @Override + public final boolean isString() { + return true; + } + + /** + * @return true if and only if the length of the string is larger than 0. + * + * Note: IMAP NIL is considered an empty string. See {@link ImapResponseParser + * #parseBareString}. + * On the other hand, a quoted/literal string with value NIL (i.e. "NIL" and {3}\r\nNIL) is + * treated literally. + */ + public final boolean isEmpty() { + return getString().length() == 0; + } + + public abstract String getString(); + + public abstract InputStream getAsStream(); + + /** + * @return whether it can be parsed as a number. + */ + public final boolean isNumber() { + if (mIsInteger) { + return true; + } + try { + mParsedInteger = Integer.parseInt(getString()); + mIsInteger = true; + return true; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * @return value parsed as a number. + */ + public final int getNumberOrZero() { + if (!isNumber()) { + return 0; + } + return mParsedInteger; + } + + /** + * @return whether it can be parsed as a date using {@link #DATE_TIME_FORMAT}. + */ + public final boolean isDate() { + if (mParsedDate != null) { + return true; + } + if (isEmpty()) { + return false; + } + try { + mParsedDate = DATE_TIME_FORMAT.parse(getString()); + return true; + } catch (ParseException e) { + Log.w(Logging.LOG_TAG, getString() + " can't be parsed as a date."); + return false; + } + } + + /** + * @return value it can be parsed as a {@link Date}, or null otherwise. + */ + public final Date getDateOrNull() { + if (!isDate()) { + return null; + } + return mParsedDate; + } + + /** + * @return whether the value case-insensitively equals to {@code s}. + */ + public final boolean is(String s) { + if (s == null) { + return false; + } + return getString().equalsIgnoreCase(s); + } + + + /** + * @return whether the value case-insensitively starts with {@code s}. + */ + public final boolean startsWith(String prefix) { + if (prefix == null) { + return false; + } + final String me = this.getString(); + if (me.length() < prefix.length()) { + return false; + } + return me.substring(0, prefix.length()).equalsIgnoreCase(prefix); + } + + // To force subclasses to implement it. + @Override + public abstract String toString(); + + @Override + public final boolean equalsForTest(ImapElement that) { + if (!super.equalsForTest(that)) { + return false; + } + ImapString thatString = (ImapString) that; + return getString().equals(thatString.getString()); + } +} diff --git a/src/com/android/email/mail/store/imap/ImapTempFileLiteral.java b/src/com/android/email/mail/store/imap/ImapTempFileLiteral.java new file mode 100644 index 000000000..eda1b568e --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapTempFileLiteral.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +import android.util.Log; + +import com.android.email.FixedLengthInputStream; +import com.android.emailcommon.Logging; +import com.android.emailcommon.TempDirectory; +import com.android.emailcommon.utility.Utility; + +import org.apache.commons.io.IOUtils; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Subclass of {@link ImapString} used for literals backed by a temp file. + */ +public class ImapTempFileLiteral extends ImapString { + /* package for test */ final File mFile; + + /** Size is purely for toString() */ + private final int mSize; + + /* package */ ImapTempFileLiteral(FixedLengthInputStream stream) throws IOException { + mSize = stream.getLength(); + mFile = File.createTempFile("imap", ".tmp", TempDirectory.getTempDirectory()); + + // Unfortunately, we can't really use deleteOnExit(), because temp filenames are random + // so it'd simply cause a memory leak. + // deleteOnExit() simply adds filenames to a static list and the list will never shrink. + // mFile.deleteOnExit(); + OutputStream out = new FileOutputStream(mFile); + IOUtils.copy(stream, out); + out.close(); + } + + /** + * Make sure we delete the temp file. + * + * We should always be calling {@link ImapResponse#destroy()}, but it's here as a last resort. + */ + @Override + protected void finalize() throws Throwable { + try { + destroy(); + } finally { + super.finalize(); + } + } + + @Override + public InputStream getAsStream() { + checkNotDestroyed(); + try { + return new FileInputStream(mFile); + } catch (FileNotFoundException e) { + // It's probably possible if we're low on storage and the system clears the cache dir. + Log.w(Logging.LOG_TAG, "ImapTempFileLiteral: Temp file not found"); + + // Return 0 byte stream as a dummy... + return new ByteArrayInputStream(new byte[0]); + } + } + + @Override + public String getString() { + checkNotDestroyed(); + try { + byte[] bytes = IOUtils.toByteArray(getAsStream()); + // Prevent crash from OOM; we've seen this, but only rarely and not reproducibly + if (bytes.length > ImapResponseParser.LITERAL_KEEP_IN_MEMORY_THRESHOLD) { + throw new IOException(); + } + return Utility.fromAscii(bytes); + } catch (IOException e) { + Log.w(Logging.LOG_TAG, "ImapTempFileLiteral: Error while reading temp file", e); + return ""; + } + } + + @Override + public void destroy() { + try { + if (!isDestroyed() && mFile.exists()) { + mFile.delete(); + } + } catch (RuntimeException re) { + // Just log and ignore. + Log.w(Logging.LOG_TAG, "Failed to remove temp file: " + re.getMessage()); + } + super.destroy(); + } + + @Override + public String toString() { + return String.format("{%d byte literal(file)}", mSize); + } + + public boolean tempFileExistsForTest() { + return mFile.exists(); + } +} diff --git a/src/com/android/email/mail/store/imap/ImapUtility.java b/src/com/android/email/mail/store/imap/ImapUtility.java new file mode 100644 index 000000000..dc7e98e96 --- /dev/null +++ b/src/com/android/email/mail/store/imap/ImapUtility.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.mail.store.imap; + +import com.android.emailcommon.Logging; + +import android.util.Log; + +import java.util.ArrayList; + +/** + * Utility methods for use with IMAP. + */ +public class ImapUtility { + /** + * Apply quoting rules per IMAP RFC, + * quoted = DQUOTE *QUOTED-CHAR DQUOTE + * QUOTED-CHAR = / "\" quoted-specials + * quoted-specials = DQUOTE / "\" + * + * This is used primarily for IMAP login, but might be useful elsewhere. + * + * NOTE: Not very efficient - you may wish to preflight this, or perhaps it should check + * for trouble chars before calling the replace functions. + * + * @param s The string to be quoted. + * @return A copy of the string, having undergone quoting as described above + */ + public static String imapQuoted(String s) { + + // First, quote any backslashes by replacing \ with \\ + // regex Pattern: \\ (Java string const = \\\\) + // Substitute: \\\\ (Java string const = \\\\\\\\) + String result = s.replaceAll("\\\\", "\\\\\\\\"); + + // Then, quote any double-quotes by replacing " with \" + // regex Pattern: " (Java string const = \") + // Substitute: \\" (Java string const = \\\\\") + result = result.replaceAll("\"", "\\\\\""); + + // return string with quotes around it + return "\"" + result + "\""; + } + + /** + * Gets all of the values in a sequence set per RFC 3501. Any ranges are expanded into a + * list of individual numbers. If the set is invalid, an empty array is returned. + *

+     * sequence-number = nz-number / "*"
+     * sequence-range  = sequence-number ":" sequence-number
+     * sequence-set    = (sequence-number / sequence-range) *("," sequence-set)
+     * 
+ */ + public static String[] getImapSequenceValues(String set) { + ArrayList list = new ArrayList(); + if (set != null) { + String[] setItems = set.split(","); + for (String item : setItems) { + if (item.indexOf(':') == -1) { + // simple item + try { + Integer.parseInt(item); // Don't need the value; just ensure it's valid + list.add(item); + } catch (NumberFormatException e) { + Log.d(Logging.LOG_TAG, "Invalid UID value", e); + } + } else { + // range + for (String rangeItem : getImapRangeValues(item)) { + list.add(rangeItem); + } + } + } + } + String[] stringList = new String[list.size()]; + return list.toArray(stringList); + } + + /** + * Expand the given number range into a list of individual numbers. If the range is not valid, + * an empty array is returned. + *
+     * sequence-number = nz-number / "*"
+     * sequence-range  = sequence-number ":" sequence-number
+     * sequence-set    = (sequence-number / sequence-range) *("," sequence-set)
+     * 
+ */ + public static String[] getImapRangeValues(String range) { + ArrayList list = new ArrayList(); + try { + if (range != null) { + int colonPos = range.indexOf(':'); + if (colonPos > 0) { + int first = Integer.parseInt(range.substring(0, colonPos)); + int second = Integer.parseInt(range.substring(colonPos + 1)); + if (first < second) { + for (int i = first; i <= second; i++) { + list.add(Integer.toString(i)); + } + } else { + for (int i = first; i >= second; i--) { + list.add(Integer.toString(i)); + } + } + } + } + } catch (NumberFormatException e) { + Log.d(Logging.LOG_TAG, "Invalid range value", e); + } + String[] stringList = new String[list.size()]; + return list.toArray(stringList); + } +} diff --git a/src/com/android/email/service/EmailBroadcastProcessorService.java b/src/com/android/email/service/EmailBroadcastProcessorService.java index abf128ec8..442feac57 100644 --- a/src/com/android/email/service/EmailBroadcastProcessorService.java +++ b/src/com/android/email/service/EmailBroadcastProcessorService.java @@ -31,17 +31,15 @@ import android.util.Log; import com.android.email.NotificationController; import com.android.email.Preferences; +import com.android.email.R; import com.android.email.SecurityPolicy; import com.android.email.activity.setup.AccountSettings; -import com.android.email.service.EmailServiceUtils.EmailServiceInfo; import com.android.emailcommon.Logging; import com.android.emailcommon.VendorPolicyLoader; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent.AccountColumns; import com.android.emailcommon.provider.HostAuth; -import java.util.List; - /** * The service that really handles broadcast intents on a worker thread. * @@ -185,7 +183,8 @@ public class EmailBroadcastProcessorService extends IntentService { while (c.moveToNext()) { long recvAuthKey = c.getLong(Account.CONTENT_HOST_AUTH_KEY_RECV_COLUMN); HostAuth recvAuth = HostAuth.restoreHostAuthWithId(context, recvAuthKey); - if (HostAuth.LEGACY_SCHEME_IMAP.equals(recvAuth.mProtocol)) { + String legacyImapProtocol = context.getString(R.string.protocol_legacy_imap); + if (legacyImapProtocol.equals(recvAuth.mProtocol)) { int flags = c.getInt(Account.CONTENT_FLAGS_COLUMN); flags &= ~Account.FLAGS_DELETE_POLICY_MASK; flags |= Account.DELETE_POLICY_ON_DELETE << Account.FLAGS_DELETE_POLICY_SHIFT; diff --git a/src/com/android/email/service/EmailServiceUtils.java b/src/com/android/email/service/EmailServiceUtils.java index 49c269786..1a071ce71 100644 --- a/src/com/android/email/service/EmailServiceUtils.java +++ b/src/com/android/email/service/EmailServiceUtils.java @@ -36,7 +36,6 @@ import android.database.Cursor; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; -import android.os.Debug; import android.os.IBinder; import android.os.RemoteException; import android.provider.CalendarContract; @@ -178,7 +177,9 @@ public class EmailServiceUtils { public boolean requiresAccountUpdate; public boolean offerLoadMore; public boolean requiresSetup; + public boolean hide; + @Override public String toString() { StringBuilder sb = new StringBuilder("Protocol: "); sb.append(protocol); @@ -288,7 +289,6 @@ public class EmailServiceUtils { protected Void doInBackground(Void... params) { disableComponent(mContext, LegacyEmailAuthenticatorService.class); disableComponent(mContext, LegacyEasAuthenticatorService.class); - disableComponent(mContext, LegacyImap2AuthenticatorService.class); return null; } } @@ -498,6 +498,7 @@ public class EmailServiceUtils { continue; } info.name = ta.getString(R.styleable.EmailServiceInfo_name); + info.hide = ta.getBoolean(R.styleable.EmailServiceInfo_hide, false); String klass = ta.getString(R.styleable.EmailServiceInfo_serviceClass); info.intentAction = ta.getString(R.styleable.EmailServiceInfo_intent); info.defaultSsl = ta.getBoolean(R.styleable.EmailServiceInfo_defaultSsl, false); diff --git a/src/com/android/email/service/ImapService.java b/src/com/android/email/service/ImapService.java new file mode 100644 index 000000000..06975419a --- /dev/null +++ b/src/com/android/email/service/ImapService.java @@ -0,0 +1,1296 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.service; + +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.Cursor; +import android.net.TrafficStats; +import android.net.Uri; +import android.os.IBinder; +import android.os.RemoteCallbackList; +import android.os.RemoteException; +import android.text.TextUtils; +import android.util.Log; + +import com.android.email.LegacyConversions; +import com.android.email.NotificationController; +import com.android.email.mail.Store; +import com.android.email.provider.Utilities; +import com.android.email2.ui.MailActivityEmail; +import com.android.emailcommon.Logging; +import com.android.emailcommon.TrafficFlags; +import com.android.emailcommon.internet.MimeUtility; +import com.android.emailcommon.mail.AuthenticationFailedException; +import com.android.emailcommon.mail.FetchProfile; +import com.android.emailcommon.mail.Flag; +import com.android.emailcommon.mail.Folder; +import com.android.emailcommon.mail.Folder.FolderType; +import com.android.emailcommon.mail.Folder.MessageRetrievalListener; +import com.android.emailcommon.mail.Folder.MessageUpdateCallbacks; +import com.android.emailcommon.mail.Folder.OpenMode; +import com.android.emailcommon.mail.Message; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Part; +import com.android.emailcommon.provider.Account; +import com.android.emailcommon.provider.EmailContent; +import com.android.emailcommon.provider.EmailContent.MailboxColumns; +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.EmailServiceCallback; +import com.android.emailcommon.service.EmailServiceStatus; +import com.android.emailcommon.service.IEmailServiceCallback; +import com.android.emailcommon.service.SearchParams; +import com.android.emailcommon.utility.AttachmentUtilities; +import com.android.mail.providers.UIProvider.AccountCapabilities; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; + +public class ImapService extends Service { + private static final String TAG = "ImapService"; + private static final int MAX_SMALL_MESSAGE_SIZE = (25 * 1024); + + private static final Flag[] FLAG_LIST_SEEN = new Flag[] { Flag.SEEN }; + private static final Flag[] FLAG_LIST_FLAGGED = new Flag[] { Flag.FLAGGED }; + private static final Flag[] FLAG_LIST_ANSWERED = new Flag[] { Flag.ANSWERED }; + + /** + * Simple cache for last search result mailbox by account and serverId, since the most common + * case will be repeated use of the same mailbox + */ + private static long mLastSearchAccountKey = Account.NO_ACCOUNT; + private static String mLastSearchServerId = null; + private static Mailbox mLastSearchRemoteMailbox = null; + + /** + * Cache search results by account; this allows for "load more" support without having to + * redo the search (which can be quite slow). SortableMessage is a smallish class, so memory + * shouldn't be an issue + */ + private static final HashMap sSearchResults = + new HashMap(); + + /** + * We write this into the serverId field of messages that will never be upsynced. + */ + private static final String LOCAL_SERVERID_PREFIX = "Local-"; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return Service.START_STICKY; + } + + // Callbacks as set up via setCallback + private static final RemoteCallbackList mCallbackList = + new RemoteCallbackList(); + + private static final EmailServiceCallback sCallbackProxy = + new EmailServiceCallback(mCallbackList); + + /** + * Create our EmailService implementation here. + */ + private final EmailServiceStub mBinder = new EmailServiceStub() { + + @Override + public void setCallback(IEmailServiceCallback cb) throws RemoteException { + mCallbackList.register(cb); + } + + @Override + public void loadMore(long messageId) throws RemoteException { + // We don't do "loadMore" for IMAP messages; the sync should handle this + } + + @Override + public int searchMessages(long accountId, SearchParams searchParams, long destMailboxId) { + try { + return searchMailboxImpl(getApplicationContext(), accountId, searchParams, + destMailboxId); + } catch (MessagingException e) { + } + return 0; + } + + @Override + public int getCapabilities(Account acct) throws RemoteException { + return AccountCapabilities.SYNCABLE_FOLDERS | + AccountCapabilities.FOLDER_SERVER_SEARCH | + AccountCapabilities.UNDO; + } + + @Override + public void serviceUpdated(String emailAddress) throws RemoteException { + // Not needed + } + }; + + @Override + public IBinder onBind(Intent intent) { + mBinder.init(this, sCallbackProxy); + return mBinder; + } + + private static void sendMailboxStatus(Mailbox mailbox, int status) { + sCallbackProxy.syncMailboxStatus(mailbox.mId, status, 0); + } + + /** + * Start foreground synchronization of the specified folder. This is called by + * synchronizeMailbox or checkMail. + * TODO this should use ID's instead of fully-restored objects + * @param account + * @param folder + * @throws MessagingException + */ + public static void synchronizeMailboxSynchronous(Context context, final Account account, + final Mailbox folder) throws MessagingException { + sendMailboxStatus(folder, EmailServiceStatus.IN_PROGRESS); + + TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(context, account)); + if ((folder.mFlags & Mailbox.FLAG_HOLDS_MAIL) == 0) { + sendMailboxStatus(folder, EmailServiceStatus.SUCCESS); + } + NotificationController nc = NotificationController.getInstance(context); + try { + processPendingActionsSynchronous(context, account); + synchronizeMailboxGeneric(context, account, folder); + // Clear authentication notification for this account + nc.cancelLoginFailedNotification(account.mId); + sendMailboxStatus(folder, EmailServiceStatus.SUCCESS); + } catch (MessagingException e) { + if (Logging.LOGD) { + Log.v(Logging.LOG_TAG, "synchronizeMailbox", e); + } + if (e instanceof AuthenticationFailedException) { + // Generate authentication notification + nc.showLoginFailedNotification(account.mId); + } + sendMailboxStatus(folder, e.getExceptionType()); + throw e; + } + } + + /** + * Lightweight record for the first pass of message sync, where I'm just seeing if + * the local message requires sync. Later (for messages that need syncing) we'll do a full + * readout from the DB. + */ + private static class LocalMessageInfo { + private static final int COLUMN_ID = 0; + private static final int COLUMN_FLAG_READ = 1; + private static final int COLUMN_FLAG_FAVORITE = 2; + private static final int COLUMN_FLAG_LOADED = 3; + private static final int COLUMN_SERVER_ID = 4; + private static final int COLUMN_FLAGS = 7; + private static final String[] PROJECTION = new String[] { + EmailContent.RECORD_ID, + MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE, MessageColumns.FLAG_LOADED, + SyncColumns.SERVER_ID, MessageColumns.MAILBOX_KEY, MessageColumns.ACCOUNT_KEY, + MessageColumns.FLAGS + }; + + final long mId; + final boolean mFlagRead; + final boolean mFlagFavorite; + final int mFlagLoaded; + final String mServerId; + final int mFlags; + + public LocalMessageInfo(Cursor c) { + mId = c.getLong(COLUMN_ID); + mFlagRead = c.getInt(COLUMN_FLAG_READ) != 0; + mFlagFavorite = c.getInt(COLUMN_FLAG_FAVORITE) != 0; + mFlagLoaded = c.getInt(COLUMN_FLAG_LOADED); + mServerId = c.getString(COLUMN_SERVER_ID); + mFlags = c.getInt(COLUMN_FLAGS); + // Note: mailbox key and account key not needed - they are projected for the SELECT + } + } + + /** + * Load the structure and body of messages not yet synced + * @param account the account we're syncing + * @param remoteFolder the (open) Folder we're working on + * @param unsyncedMessages an array of Message's we've got headers for + * @param toMailbox the destination mailbox we're syncing + * @throws MessagingException + */ + static void loadUnsyncedMessages(final Context context, final Account account, + Folder remoteFolder, ArrayList messages, final Mailbox toMailbox) + throws MessagingException { + + FetchProfile fp = new FetchProfile(); + fp.add(FetchProfile.Item.STRUCTURE); + remoteFolder.fetch(messages.toArray(new Message[messages.size()]), fp, null); + for (Message message : messages) { + // Build a list of parts we are interested in. Text parts will be downloaded + // right now, attachments will be left for later. + ArrayList viewables = new ArrayList(); + ArrayList attachments = new ArrayList(); + MimeUtility.collectParts(message, viewables, attachments); + // Download the viewables immediately + for (Part part : viewables) { + fp.clear(); + fp.add(part); + remoteFolder.fetch(new Message[] { message }, fp, null); + } + // Store the updated message locally and mark it fully loaded + Utilities.copyOneMessageToProvider(context, message, account, toMailbox, + EmailContent.Message.FLAG_LOADED_COMPLETE); + } + } + + public static void downloadFlagAndEnvelope(final Context context, final Account account, + final Mailbox mailbox, Folder remoteFolder, ArrayList unsyncedMessages, + HashMap localMessageMap, final ArrayList unseenMessages) + throws MessagingException { + FetchProfile fp = new FetchProfile(); + fp.add(FetchProfile.Item.FLAGS); + fp.add(FetchProfile.Item.ENVELOPE); + + final HashMap localMapCopy; + if (localMessageMap != null) + localMapCopy = new HashMap(localMessageMap); + else { + localMapCopy = new HashMap(); + } + + remoteFolder.fetch(unsyncedMessages.toArray(new Message[0]), fp, + new MessageRetrievalListener() { + @Override + public void messageRetrieved(Message message) { + try { + // Determine if the new message was already known (e.g. partial) + // And create or reload the full message info + LocalMessageInfo localMessageInfo = + localMapCopy.get(message.getUid()); + EmailContent.Message localMessage = null; + if (localMessageInfo == null) { + localMessage = new EmailContent.Message(); + } else { + localMessage = EmailContent.Message.restoreMessageWithId( + context, localMessageInfo.mId); + } + + if (localMessage != null) { + try { + // Copy the fields that are available into the message + LegacyConversions.updateMessageFields(localMessage, + message, account.mId, mailbox.mId); + // Commit the message to the local store + Utilities.saveOrUpdate(localMessage, context); + // Track the "new" ness of the downloaded message + if (!message.isSet(Flag.SEEN) && unseenMessages != null) { + unseenMessages.add(localMessage.mId); + } + } catch (MessagingException me) { + Log.e(Logging.LOG_TAG, + "Error while copying downloaded message." + me); + } + + } + } + catch (Exception e) { + Log.e(Logging.LOG_TAG, + "Error while storing downloaded message." + e.toString()); + } + } + + @Override + public void loadAttachmentProgress(int progress) { + } + }); + + } + + /** + * Synchronizer for IMAP. + * + * TODO Break this method up into smaller chunks. + * + * @param account the account to sync + * @param mailbox the mailbox to sync + * @return results of the sync pass + * @throws MessagingException + */ + private static void synchronizeMailboxGeneric(final Context context, + final Account account, final Mailbox mailbox) throws MessagingException { + + /* + * A list of IDs for messages that were downloaded and did not have the seen flag set. + * This serves as the "true" new message count reported to the user via notification. + */ + final ArrayList unseenMessages = new ArrayList(); + + ContentResolver resolver = context.getContentResolver(); + + // 0. We do not ever sync DRAFTS or OUTBOX (down or up) + if (mailbox.mType == Mailbox.TYPE_DRAFTS || mailbox.mType == Mailbox.TYPE_OUTBOX) { + return; + } + + // 1. Get the message list from the local store and create an index of the uids + + Cursor localUidCursor = null; + HashMap localMessageMap = new HashMap(); + + try { + localUidCursor = resolver.query( + EmailContent.Message.CONTENT_URI, + LocalMessageInfo.PROJECTION, + EmailContent.MessageColumns.ACCOUNT_KEY + "=?" + + " AND " + MessageColumns.MAILBOX_KEY + "=?", + new String[] { + String.valueOf(account.mId), + String.valueOf(mailbox.mId) + }, + null); + while (localUidCursor.moveToNext()) { + LocalMessageInfo info = new LocalMessageInfo(localUidCursor); + localMessageMap.put(info.mServerId, info); + } + } finally { + if (localUidCursor != null) { + localUidCursor.close(); + } + } + + // 2. Open the remote folder and create the remote folder if necessary + + Store remoteStore = Store.getInstance(account, context); + // The account might have been deleted + if (remoteStore == null) return; + Folder remoteFolder = remoteStore.getFolder(mailbox.mServerId); + + /* + * If the folder is a "special" folder we need to see if it exists + * on the remote server. It if does not exist we'll try to create it. If we + * can't create we'll abort. This will happen on every single Pop3 folder as + * designed and on Imap folders during error conditions. This allows us + * to treat Pop3 and Imap the same in this code. + */ + if (mailbox.mType == Mailbox.TYPE_TRASH || mailbox.mType == Mailbox.TYPE_SENT + || mailbox.mType == Mailbox.TYPE_DRAFTS) { + if (!remoteFolder.exists()) { + if (!remoteFolder.create(FolderType.HOLDS_MESSAGES)) { + return; + } + } + } + + // 3, Open the remote folder. This pre-loads certain metadata like message count. + remoteFolder.open(OpenMode.READ_WRITE); + + // 4. Trash any remote messages that are marked as trashed locally. + // TODO - this comment was here, but no code was here. + + // 5. Get the remote message count. + int remoteMessageCount = remoteFolder.getMessageCount(); + ContentValues values = new ContentValues(); + values.put(MailboxColumns.TOTAL_COUNT, remoteMessageCount); + mailbox.update(context, values); + + // 6. Determine the limit # of messages to download + int visibleLimit = mailbox.mVisibleLimit; + if (visibleLimit <= 0) { + visibleLimit = MailActivityEmail.VISIBLE_LIMIT_DEFAULT; + } + + // 7. Create a list of messages to download + Message[] remoteMessages = new Message[0]; + final ArrayList unsyncedMessages = new ArrayList(); + HashMap remoteUidMap = new HashMap(); + + if (remoteMessageCount > 0) { + /* + * Message numbers start at 1. + */ + int remoteStart = Math.max(0, remoteMessageCount - visibleLimit) + 1; + int remoteEnd = remoteMessageCount; + remoteMessages = remoteFolder.getMessages(remoteStart, remoteEnd, null); + // TODO Why are we running through the list twice? Combine w/ for loop below + for (Message message : remoteMessages) { + remoteUidMap.put(message.getUid(), message); + } + + /* + * Get a list of the messages that are in the remote list but not on the + * local store, or messages that are in the local store but failed to download + * on the last sync. These are the new messages that we will download. + * Note, we also skip syncing messages which are flagged as "deleted message" sentinels, + * because they are locally deleted and we don't need or want the old message from + * the server. + */ + for (Message message : remoteMessages) { + LocalMessageInfo localMessage = localMessageMap.get(message.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(message); + } + } + } + + // 8. 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(context, account, mailbox, remoteFolder, unsyncedMessages, + localMessageMap, unseenMessages); + } + + // 9. Refresh the flags for any messages in the local store that we didn't just download. + 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; + } + } + // Update SEEN/FLAGGED/ANSWERED (star) flags (if supported remotely - e.g. not for POP3) + 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); + } + } + } + + // 10. Remove any messages that are in the local store but no longer on the remote store. + HashSet localUidsToDelete = new HashSet(localMessageMap.keySet()); + localUidsToDelete.removeAll(remoteUidMap.keySet()); + for (String uidToDelete : localUidsToDelete) { + LocalMessageInfo infoToDelete = localMessageMap.get(uidToDelete); + + // Delete associated data (attachment files) + // Attachment & Body records are auto-deleted when we delete the Message record + AttachmentUtilities.deleteAllAttachmentFiles(context, account.mId, + infoToDelete.mId); + + // Delete the message itself + Uri uriToDelete = ContentUris.withAppendedId( + EmailContent.Message.CONTENT_URI, infoToDelete.mId); + resolver.delete(uriToDelete, null, null); + + // Delete extra rows (e.g. synced or deleted) + Uri syncRowToDelete = ContentUris.withAppendedId( + EmailContent.Message.UPDATED_CONTENT_URI, infoToDelete.mId); + resolver.delete(syncRowToDelete, null, null); + Uri deletERowToDelete = ContentUris.withAppendedId( + EmailContent.Message.UPDATED_CONTENT_URI, infoToDelete.mId); + resolver.delete(deletERowToDelete, null, null); + } + + loadUnsyncedMessages(context, account, remoteFolder, unsyncedMessages, mailbox); + + // 14. Clean up and report results + remoteFolder.close(false); + } + + /** + * Find messages in the updated table that need to be written back to server. + * + * Handles: + * Read/Unread + * Flagged + * Append (upload) + * Move To Trash + * Empty trash + * TODO: + * Move + * + * @param account the account to scan for pending actions + * @throws MessagingException + */ + private static void processPendingActionsSynchronous(Context context, Account account) + throws MessagingException { + TrafficStats.setThreadStatsTag(TrafficFlags.getSyncFlags(context, account)); + String[] accountIdArgs = new String[] { Long.toString(account.mId) }; + + // Handle deletes first, it's always better to get rid of things first + processPendingDeletesSynchronous(context, account, accountIdArgs); + + // Handle uploads (currently, only to sent messages) + processPendingUploadsSynchronous(context, account, accountIdArgs); + + // Now handle updates / upsyncs + processPendingUpdatesSynchronous(context, account, accountIdArgs); + } + + /** + * Get the mailbox corresponding to the remote location of a message; this will normally be + * the mailbox whose _id is mailboxKey, except for search results, where we must look it up + * by serverId + * @param message the message in question + * @return the mailbox in which the message resides on the server + */ + private static Mailbox getRemoteMailboxForMessage(Context context, + EmailContent.Message message) { + // If this is a search result, use the protocolSearchInfo field to get the server info + if (!TextUtils.isEmpty(message.mProtocolSearchInfo)) { + long accountKey = message.mAccountKey; + String protocolSearchInfo = message.mProtocolSearchInfo; + if (accountKey == mLastSearchAccountKey && + protocolSearchInfo.equals(mLastSearchServerId)) { + return mLastSearchRemoteMailbox; + } + Cursor c = context.getContentResolver().query(Mailbox.CONTENT_URI, + Mailbox.CONTENT_PROJECTION, Mailbox.PATH_AND_ACCOUNT_SELECTION, + new String[] {protocolSearchInfo, Long.toString(accountKey)}, + null); + try { + if (c.moveToNext()) { + Mailbox mailbox = new Mailbox(); + mailbox.restore(c); + mLastSearchAccountKey = accountKey; + mLastSearchServerId = protocolSearchInfo; + mLastSearchRemoteMailbox = mailbox; + return mailbox; + } else { + return null; + } + } finally { + c.close(); + } + } else { + return Mailbox.restoreMailboxWithId(context, message.mMailboxKey); + } + } + + /** + * Scan for messages that are in the Message_Deletes table, look for differences that + * we can deal with, and do the work. + * + * @param account + * @param resolver + * @param accountIdArgs + */ + private static void processPendingDeletesSynchronous(Context context, Account account, + String[] accountIdArgs) { + Cursor deletes = context.getContentResolver().query( + EmailContent.Message.DELETED_CONTENT_URI, + EmailContent.Message.CONTENT_PROJECTION, + EmailContent.MessageColumns.ACCOUNT_KEY + "=?", accountIdArgs, + EmailContent.MessageColumns.MAILBOX_KEY); + long lastMessageId = -1; + try { + // Defer setting up the store until we know we need to access it + Store remoteStore = null; + // loop through messages marked as deleted + while (deletes.moveToNext()) { + boolean deleteFromTrash = false; + + EmailContent.Message oldMessage = + EmailContent.getContent(deletes, EmailContent.Message.class); + + if (oldMessage != null) { + lastMessageId = oldMessage.mId; + + Mailbox mailbox = getRemoteMailboxForMessage(context, oldMessage); + if (mailbox == null) { + continue; // Mailbox removed. Move to the next message. + } + deleteFromTrash = mailbox.mType == Mailbox.TYPE_TRASH; + + // Load the remote store if it will be needed + if (remoteStore == null && deleteFromTrash) { + remoteStore = Store.getInstance(account, context); + } + + // Dispatch here for specific change types + if (deleteFromTrash) { + // Move message to trash + processPendingDeleteFromTrash(context, remoteStore, account, mailbox, + oldMessage); + } + } + + // Finally, delete the update + Uri uri = ContentUris.withAppendedId(EmailContent.Message.DELETED_CONTENT_URI, + oldMessage.mId); + context.getContentResolver().delete(uri, null, null); + } + } catch (MessagingException me) { + // Presumably an error here is an account connection failure, so there is + // no point in continuing through the rest of the pending updates. + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, "Unable to process pending delete for id=" + + lastMessageId + ": " + me); + } + } finally { + deletes.close(); + } + } + + /** + * Scan for messages that are in Sent, and are in need of upload, + * and send them to the server. "In need of upload" is defined as: + * serverId == null (no UID has been assigned) + * or + * message is in the updated list + * + * Note we also look for messages that are moving from drafts->outbox->sent. They never + * go through "drafts" or "outbox" on the server, so we hang onto these until they can be + * uploaded directly to the Sent folder. + * + * @param account + * @param resolver + * @param accountIdArgs + */ + private static void processPendingUploadsSynchronous(Context context, Account account, + String[] accountIdArgs) { + ContentResolver resolver = context.getContentResolver(); + // Find the Sent folder (since that's all we're uploading for now + Cursor mailboxes = resolver.query(Mailbox.CONTENT_URI, Mailbox.ID_PROJECTION, + MailboxColumns.ACCOUNT_KEY + "=?" + + " and " + MailboxColumns.TYPE + "=" + Mailbox.TYPE_SENT, + accountIdArgs, null); + long lastMessageId = -1; + try { + // Defer setting up the store until we know we need to access it + Store remoteStore = null; + while (mailboxes.moveToNext()) { + long mailboxId = mailboxes.getLong(Mailbox.ID_PROJECTION_COLUMN); + String[] mailboxKeyArgs = new String[] { Long.toString(mailboxId) }; + // Demand load mailbox + Mailbox mailbox = null; + + // First handle the "new" messages (serverId == null) + Cursor upsyncs1 = resolver.query(EmailContent.Message.CONTENT_URI, + EmailContent.Message.ID_PROJECTION, + EmailContent.Message.MAILBOX_KEY + "=?" + + " and (" + EmailContent.Message.SERVER_ID + " is null" + + " or " + EmailContent.Message.SERVER_ID + "=''" + ")", + mailboxKeyArgs, + null); + try { + while (upsyncs1.moveToNext()) { + // Load the remote store if it will be needed + if (remoteStore == null) { + remoteStore = Store.getInstance(account, context); + } + // Load the mailbox if it will be needed + if (mailbox == null) { + mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); + if (mailbox == null) { + continue; // Mailbox removed. Move to the next message. + } + } + // upsync the message + long id = upsyncs1.getLong(EmailContent.Message.ID_PROJECTION_COLUMN); + lastMessageId = id; + processUploadMessage(context, remoteStore, account, mailbox, id); + } + } finally { + if (upsyncs1 != null) { + upsyncs1.close(); + } + } + + // Next, handle any updates (e.g. edited in place, although this shouldn't happen) + Cursor upsyncs2 = resolver.query(EmailContent.Message.UPDATED_CONTENT_URI, + EmailContent.Message.ID_PROJECTION, + EmailContent.MessageColumns.MAILBOX_KEY + "=?", mailboxKeyArgs, + null); + try { + while (upsyncs2.moveToNext()) { + // Load the remote store if it will be needed + if (remoteStore == null) { + remoteStore = Store.getInstance(account, context); + } + // Load the mailbox if it will be needed + if (mailbox == null) { + mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); + if (mailbox == null) { + continue; // Mailbox removed. Move to the next message. + } + } + // upsync the message + long id = upsyncs2.getLong(EmailContent.Message.ID_PROJECTION_COLUMN); + lastMessageId = id; + processUploadMessage(context, remoteStore, account, mailbox, id); + } + } finally { + if (upsyncs2 != null) { + upsyncs2.close(); + } + } + } + } catch (MessagingException me) { + // Presumably an error here is an account connection failure, so there is + // no point in continuing through the rest of the pending updates. + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, "Unable to process pending upsync for id=" + + lastMessageId + ": " + me); + } + } finally { + if (mailboxes != null) { + mailboxes.close(); + } + } + } + + /** + * Scan for messages that are in the Message_Updates table, look for differences that + * we can deal with, and do the work. + * + * @param account + * @param resolver + * @param accountIdArgs + */ + private static void processPendingUpdatesSynchronous(Context context, Account account, + String[] accountIdArgs) { + ContentResolver resolver = context.getContentResolver(); + Cursor updates = resolver.query(EmailContent.Message.UPDATED_CONTENT_URI, + EmailContent.Message.CONTENT_PROJECTION, + EmailContent.MessageColumns.ACCOUNT_KEY + "=?", accountIdArgs, + EmailContent.MessageColumns.MAILBOX_KEY); + long lastMessageId = -1; + try { + // Defer setting up the store until we know we need to access it + Store remoteStore = null; + // Demand load mailbox (note order-by to reduce thrashing here) + Mailbox mailbox = null; + // loop through messages marked as needing updates + while (updates.moveToNext()) { + boolean changeMoveToTrash = false; + boolean changeRead = false; + boolean changeFlagged = false; + boolean changeMailbox = false; + boolean changeAnswered = false; + + EmailContent.Message oldMessage = + EmailContent.getContent(updates, EmailContent.Message.class); + lastMessageId = oldMessage.mId; + EmailContent.Message newMessage = + EmailContent.Message.restoreMessageWithId(context, oldMessage.mId); + if (newMessage != null) { + mailbox = Mailbox.restoreMailboxWithId(context, newMessage.mMailboxKey); + if (mailbox == null) { + continue; // Mailbox removed. Move to the next message. + } + if (oldMessage.mMailboxKey != newMessage.mMailboxKey) { + if (mailbox.mType == Mailbox.TYPE_TRASH) { + changeMoveToTrash = true; + } else { + changeMailbox = true; + } + } + changeRead = oldMessage.mFlagRead != newMessage.mFlagRead; + changeFlagged = oldMessage.mFlagFavorite != newMessage.mFlagFavorite; + changeAnswered = (oldMessage.mFlags & EmailContent.Message.FLAG_REPLIED_TO) != + (newMessage.mFlags & EmailContent.Message.FLAG_REPLIED_TO); + } + + // Load the remote store if it will be needed + if (remoteStore == null && + (changeMoveToTrash || changeRead || changeFlagged || changeMailbox || + changeAnswered)) { + remoteStore = Store.getInstance(account, context); + } + + // Dispatch here for specific change types + if (changeMoveToTrash) { + // Move message to trash + processPendingMoveToTrash(context, remoteStore, account, mailbox, oldMessage, + newMessage); + } else if (changeRead || changeFlagged || changeMailbox || changeAnswered) { + processPendingDataChange(context, remoteStore, mailbox, changeRead, + changeFlagged, changeMailbox, changeAnswered, oldMessage, newMessage); + } + + // Finally, delete the update + Uri uri = ContentUris.withAppendedId(EmailContent.Message.UPDATED_CONTENT_URI, + oldMessage.mId); + resolver.delete(uri, null, null); + } + + } catch (MessagingException me) { + // Presumably an error here is an account connection failure, so there is + // no point in continuing through the rest of the pending updates. + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, "Unable to process pending update for id=" + + lastMessageId + ": " + me); + } + } finally { + updates.close(); + } + } + + /** + * Upsync an entire message. This must also unwind whatever triggered it (either by + * updating the serverId, or by deleting the update record, or it's going to keep happening + * over and over again. + * + * Note: If the message is being uploaded into an unexpected mailbox, we *do not* upload. + * This is to avoid unnecessary uploads into the trash. Although the caller attempts to select + * only the Drafts and Sent folders, this can happen when the update record and the current + * record mismatch. In this case, we let the update record remain, because the filters + * in processPendingUpdatesSynchronous() will pick it up as a move and handle it (or drop it) + * appropriately. + * + * @param resolver + * @param remoteStore + * @param account + * @param mailbox the actual mailbox + * @param messageId + */ + private static void processUploadMessage(Context context, Store remoteStore, + Account account, Mailbox mailbox, long messageId) + throws MessagingException { + EmailContent.Message newMessage = + EmailContent.Message.restoreMessageWithId(context, messageId); + boolean deleteUpdate = false; + if (newMessage == null) { + deleteUpdate = true; + Log.d(Logging.LOG_TAG, "Upsync failed for null message, id=" + messageId); + } else if (mailbox.mType == Mailbox.TYPE_DRAFTS) { + deleteUpdate = false; + Log.d(Logging.LOG_TAG, "Upsync skipped for mailbox=drafts, id=" + messageId); + } else if (mailbox.mType == Mailbox.TYPE_OUTBOX) { + deleteUpdate = false; + Log.d(Logging.LOG_TAG, "Upsync skipped for mailbox=outbox, id=" + messageId); + } else if (mailbox.mType == Mailbox.TYPE_TRASH) { + deleteUpdate = false; + Log.d(Logging.LOG_TAG, "Upsync skipped for mailbox=trash, id=" + messageId); + } else if (newMessage != null && newMessage.mMailboxKey != mailbox.mId) { + deleteUpdate = false; + Log.d(Logging.LOG_TAG, "Upsync skipped; mailbox changed, id=" + messageId); + } else { +// Log.d(Logging.LOG_TAG, "Upsyc triggered for message id=" + messageId); +// deleteUpdate = processPendingAppend(context, remoteStore, account, mailbox, + //newMessage); + } + if (deleteUpdate) { + // Finally, delete the update (if any) + Uri uri = ContentUris.withAppendedId( + EmailContent.Message.UPDATED_CONTENT_URI, messageId); + context.getContentResolver().delete(uri, null, null); + } + } + + /** + * Upsync changes to read, flagged, or mailbox + * + * @param remoteStore the remote store for this mailbox + * @param mailbox the mailbox the message is stored in + * @param changeRead whether the message's read state has changed + * @param changeFlagged whether the message's flagged state has changed + * @param changeMailbox whether the message's mailbox has changed + * @param oldMessage the message in it's pre-change state + * @param newMessage the current version of the message + */ + private static void processPendingDataChange(final Context context, Store remoteStore, + Mailbox mailbox, boolean changeRead, boolean changeFlagged, boolean changeMailbox, + boolean changeAnswered, EmailContent.Message oldMessage, + final EmailContent.Message newMessage) throws MessagingException { + // New mailbox is the mailbox this message WILL be in (same as the one it WAS in if it isn't + // being moved + Mailbox newMailbox = mailbox; + // Mailbox is the original remote mailbox (the one we're acting on) + mailbox = getRemoteMailboxForMessage(context, oldMessage); + + // 0. No remote update if the message is local-only + if (newMessage.mServerId == null || newMessage.mServerId.equals("") + || newMessage.mServerId.startsWith(LOCAL_SERVERID_PREFIX) || (mailbox == null)) { + return; + } + + // 1. No remote update for DRAFTS or OUTBOX + if (mailbox.mType == Mailbox.TYPE_DRAFTS || mailbox.mType == Mailbox.TYPE_OUTBOX) { + return; + } + + // 2. Open the remote store & folder + Folder remoteFolder = remoteStore.getFolder(mailbox.mServerId); + if (!remoteFolder.exists()) { + return; + } + remoteFolder.open(OpenMode.READ_WRITE); + if (remoteFolder.getMode() != OpenMode.READ_WRITE) { + return; + } + + // 3. Finally, apply the changes to the message + Message remoteMessage = remoteFolder.getMessage(newMessage.mServerId); + if (remoteMessage == null) { + return; + } + if (MailActivityEmail.DEBUG) { + Log.d(Logging.LOG_TAG, + "Update for msg id=" + newMessage.mId + + " read=" + newMessage.mFlagRead + + " flagged=" + newMessage.mFlagFavorite + + " answered=" + + ((newMessage.mFlags & EmailContent.Message.FLAG_REPLIED_TO) != 0) + + " new mailbox=" + newMessage.mMailboxKey); + } + Message[] messages = new Message[] { remoteMessage }; + if (changeRead) { + remoteFolder.setFlags(messages, FLAG_LIST_SEEN, newMessage.mFlagRead); + } + if (changeFlagged) { + remoteFolder.setFlags(messages, FLAG_LIST_FLAGGED, newMessage.mFlagFavorite); + } + if (changeAnswered) { + remoteFolder.setFlags(messages, FLAG_LIST_ANSWERED, + (newMessage.mFlags & EmailContent.Message.FLAG_REPLIED_TO) != 0); + } + if (changeMailbox) { + Folder toFolder = remoteStore.getFolder(newMailbox.mServerId); + if (!remoteFolder.exists()) { + return; + } + // We may need the message id to search for the message in the destination folder + remoteMessage.setMessageId(newMessage.mMessageId); + // Copy the message to its new folder + remoteFolder.copyMessages(messages, toFolder, new MessageUpdateCallbacks() { + @Override + public void onMessageUidChange(Message message, String newUid) { + ContentValues cv = new ContentValues(); + cv.put(EmailContent.Message.SERVER_ID, newUid); + // We only have one message, so, any updates _must_ be for it. Otherwise, + // we'd have to cycle through to find the one with the same server ID. + context.getContentResolver().update(ContentUris.withAppendedId( + EmailContent.Message.CONTENT_URI, newMessage.mId), cv, null, null); + } + @Override + public void onMessageNotFound(Message message) { + } + }); + // Delete the message from the remote source folder + remoteMessage.setFlag(Flag.DELETED, true); + remoteFolder.expunge(); + } + remoteFolder.close(false); + } + + /** + * Process a pending trash message command. + * + * @param remoteStore the remote store we're working in + * @param account The account in which we are working + * @param newMailbox The local trash mailbox + * @param oldMessage The message copy that was saved in the updates shadow table + * @param newMessage The message that was moved to the mailbox + */ + private static void processPendingMoveToTrash(final Context context, Store remoteStore, + Account account, Mailbox newMailbox, EmailContent.Message oldMessage, + final EmailContent.Message newMessage) throws MessagingException { + + // 0. No remote move if the message is local-only + if (newMessage.mServerId == null || newMessage.mServerId.equals("") + || newMessage.mServerId.startsWith(LOCAL_SERVERID_PREFIX)) { + return; + } + + // 1. Escape early if we can't find the local mailbox + // TODO smaller projection here + Mailbox oldMailbox = getRemoteMailboxForMessage(context, oldMessage); + if (oldMailbox == null) { + // can't find old mailbox, it may have been deleted. just return. + return; + } + // 2. We don't support delete-from-trash here + if (oldMailbox.mType == Mailbox.TYPE_TRASH) { + return; + } + + // The rest of this method handles server-side deletion + + // 4. Find the remote mailbox (that we deleted from), and open it + Folder remoteFolder = remoteStore.getFolder(oldMailbox.mServerId); + if (!remoteFolder.exists()) { + return; + } + + remoteFolder.open(OpenMode.READ_WRITE); + if (remoteFolder.getMode() != OpenMode.READ_WRITE) { + remoteFolder.close(false); + return; + } + + // 5. Find the remote original message + Message remoteMessage = remoteFolder.getMessage(oldMessage.mServerId); + if (remoteMessage == null) { + remoteFolder.close(false); + return; + } + + // 6. Find the remote trash folder, and create it if not found + Folder remoteTrashFolder = remoteStore.getFolder(newMailbox.mServerId); + if (!remoteTrashFolder.exists()) { + /* + * If the remote trash folder doesn't exist we try to create it. + */ + remoteTrashFolder.create(FolderType.HOLDS_MESSAGES); + } + + // 7. Try to copy the message into the remote trash folder + // Note, this entire section will be skipped for POP3 because there's no remote trash + if (remoteTrashFolder.exists()) { + /* + * Because remoteTrashFolder may be new, we need to explicitly open it + */ + remoteTrashFolder.open(OpenMode.READ_WRITE); + if (remoteTrashFolder.getMode() != OpenMode.READ_WRITE) { + remoteFolder.close(false); + remoteTrashFolder.close(false); + return; + } + + remoteFolder.copyMessages(new Message[] { remoteMessage }, remoteTrashFolder, + new Folder.MessageUpdateCallbacks() { + @Override + public void onMessageUidChange(Message message, String newUid) { + // update the UID in the local trash folder, because some stores will + // have to change it when copying to remoteTrashFolder + ContentValues cv = new ContentValues(); + cv.put(EmailContent.Message.SERVER_ID, newUid); + context.getContentResolver().update(newMessage.getUri(), cv, null, null); + } + + /** + * This will be called if the deleted message doesn't exist and can't be + * deleted (e.g. it was already deleted from the server.) In this case, + * attempt to delete the local copy as well. + */ + @Override + public void onMessageNotFound(Message message) { + context.getContentResolver().delete(newMessage.getUri(), null, null); + } + }); + remoteTrashFolder.close(false); + } + + // 8. Delete the message from the remote source folder + remoteMessage.setFlag(Flag.DELETED, true); + remoteFolder.expunge(); + remoteFolder.close(false); + } + + /** + * Process a pending trash message command. + * + * @param remoteStore the remote store we're working in + * @param account The account in which we are working + * @param oldMailbox The local trash mailbox + * @param oldMessage The message that was deleted from the trash + */ + private static void processPendingDeleteFromTrash(Context context, Store remoteStore, + Account account, Mailbox oldMailbox, EmailContent.Message oldMessage) + throws MessagingException { + + // 1. We only support delete-from-trash here + if (oldMailbox.mType != Mailbox.TYPE_TRASH) { + return; + } + + // 2. Find the remote trash folder (that we are deleting from), and open it + Folder remoteTrashFolder = remoteStore.getFolder(oldMailbox.mServerId); + if (!remoteTrashFolder.exists()) { + return; + } + + remoteTrashFolder.open(OpenMode.READ_WRITE); + if (remoteTrashFolder.getMode() != OpenMode.READ_WRITE) { + remoteTrashFolder.close(false); + return; + } + + // 3. Find the remote original message + Message remoteMessage = remoteTrashFolder.getMessage(oldMessage.mServerId); + if (remoteMessage == null) { + remoteTrashFolder.close(false); + return; + } + + // 4. Delete the message from the remote trash folder + remoteMessage.setFlag(Flag.DELETED, true); + remoteTrashFolder.expunge(); + remoteTrashFolder.close(false); + } + + /** + * A message and numeric uid that's easily sortable + */ + private static class SortableMessage { + private final Message mMessage; + private final long mUid; + + SortableMessage(Message message, long uid) { + mMessage = message; + mUid = uid; + } + } + + private int searchMailboxImpl(final Context context, long accountId, SearchParams searchParams, + final long destMailboxId) throws MessagingException { + final Account account = Account.restoreAccountWithId(context, accountId); + final Mailbox mailbox = Mailbox.restoreMailboxWithId(context, searchParams.mMailboxId); + final Mailbox destMailbox = Mailbox.restoreMailboxWithId(context, destMailboxId); + if (account == null || mailbox == null || destMailbox == null) { + Log.d(Logging.LOG_TAG, "Attempted search for " + searchParams + + " but account or mailbox information was missing"); + return 0; + } + + // Tell UI that we're loading messages + + Store remoteStore = Store.getInstance(account, context); + Folder remoteFolder = remoteStore.getFolder(mailbox.mServerId); + remoteFolder.open(OpenMode.READ_WRITE); + + SortableMessage[] sortableMessages = new SortableMessage[0]; + if (searchParams.mOffset == 0) { + // Get the "bare" messages (basically uid) + Message[] remoteMessages = remoteFolder.getMessages(searchParams, null); + int remoteCount = remoteMessages.length; + if (remoteCount > 0) { + sortableMessages = new SortableMessage[remoteCount]; + int i = 0; + for (Message msg : remoteMessages) { + sortableMessages[i++] = new SortableMessage(msg, Long.parseLong(msg.getUid())); + } + // Sort the uid's, most recent first + // Note: Not all servers will be nice and return results in the order of request; + // those that do will see messages arrive from newest to oldest + Arrays.sort(sortableMessages, new Comparator() { + @Override + public int compare(SortableMessage lhs, SortableMessage rhs) { + return lhs.mUid > rhs.mUid ? -1 : lhs.mUid < rhs.mUid ? 1 : 0; + } + }); + sSearchResults.put(accountId, sortableMessages); + } + } else { + sortableMessages = sSearchResults.get(accountId); + } + + final int numSearchResults = sortableMessages.length; + final int numToLoad = + Math.min(numSearchResults - searchParams.mOffset, searchParams.mLimit); + if (numToLoad <= 0) { + return 0; + } + + final ArrayList messageList = new ArrayList(); + for (int i = searchParams.mOffset; i < numToLoad + searchParams.mOffset; i++) { + messageList.add(sortableMessages[i].mMessage); + } + // Get everything in one pass, rather than two (as in sync); this starts getting us + // usable results quickly. + FetchProfile fp = new FetchProfile(); + fp.add(FetchProfile.Item.FLAGS); + fp.add(FetchProfile.Item.ENVELOPE); + fp.add(FetchProfile.Item.STRUCTURE); + fp.add(FetchProfile.Item.BODY_SANE); + remoteFolder.fetch(messageList.toArray(new Message[0]), fp, + new MessageRetrievalListener() { + @Override + public void messageRetrieved(Message message) { + try { + // Determine if the new message was already known (e.g. partial) + // And create or reload the full message info + EmailContent.Message localMessage = new EmailContent.Message(); + try { + // Copy the fields that are available into the message + LegacyConversions.updateMessageFields(localMessage, + message, account.mId, mailbox.mId); + // Commit the message to the local store + Utilities.saveOrUpdate(localMessage, context); + localMessage.mMailboxKey = destMailboxId; + // We load 50k or so; maybe it's complete, maybe not... + int flag = EmailContent.Message.FLAG_LOADED_COMPLETE; + // We store the serverId of the source mailbox into protocolSearchInfo + // This will be used by loadMessageForView, etc. to use the proper remote + // folder + localMessage.mProtocolSearchInfo = mailbox.mServerId; + if (message.getSize() > Store.FETCH_BODY_SANE_SUGGESTED_SIZE) { + flag = EmailContent.Message.FLAG_LOADED_PARTIAL; + } + Utilities.copyOneMessageToProvider(context, message, localMessage, flag); + } catch (MessagingException me) { + Log.e(Logging.LOG_TAG, + "Error while copying downloaded message." + me); + } + } catch (Exception e) { + Log.e(Logging.LOG_TAG, + "Error while storing downloaded message." + e.toString()); + } + } + + @Override + public void loadAttachmentProgress(int progress) { + } + }); + return numSearchResults; + } +} \ No newline at end of file diff --git a/src/com/android/email/service/ImapTempFileLiteral.java b/src/com/android/email/service/ImapTempFileLiteral.java new file mode 100644 index 000000000..cc1dd5410 --- /dev/null +++ b/src/com/android/email/service/ImapTempFileLiteral.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.service; + +import android.util.Log; + +import com.android.email.FixedLengthInputStream; +import com.android.email.mail.store.imap.ImapResponse; +import com.android.email.mail.store.imap.ImapResponseParser; +import com.android.email.mail.store.imap.ImapString; +import com.android.emailcommon.Logging; +import com.android.emailcommon.TempDirectory; +import com.android.emailcommon.utility.Utility; + +import org.apache.commons.io.IOUtils; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Subclass of {@link ImapString} used for literals backed by a temp file. + */ +public class ImapTempFileLiteral extends ImapString { + /* package for test */ final File mFile; + + /** Size is purely for toString() */ + private final int mSize; + + /* package */ ImapTempFileLiteral(FixedLengthInputStream stream) throws IOException { + mSize = stream.getLength(); + mFile = File.createTempFile("imap", ".tmp", TempDirectory.getTempDirectory()); + + // Unfortunately, we can't really use deleteOnExit(), because temp filenames are random + // so it'd simply cause a memory leak. + // deleteOnExit() simply adds filenames to a static list and the list will never shrink. + // mFile.deleteOnExit(); + OutputStream out = new FileOutputStream(mFile); + IOUtils.copy(stream, out); + out.close(); + } + + /** + * Make sure we delete the temp file. + * + * We should always be calling {@link ImapResponse#destroy()}, but it's here as a last resort. + */ + @Override + protected void finalize() throws Throwable { + try { + destroy(); + } finally { + super.finalize(); + } + } + + @Override + public InputStream getAsStream() { + checkNotDestroyed(); + try { + return new FileInputStream(mFile); + } catch (FileNotFoundException e) { + // It's probably possible if we're low on storage and the system clears the cache dir. + Log.w(Logging.LOG_TAG, "ImapTempFileLiteral: Temp file not found"); + + // Return 0 byte stream as a dummy... + return new ByteArrayInputStream(new byte[0]); + } + } + + @Override + public String getString() { + checkNotDestroyed(); + try { + byte[] bytes = IOUtils.toByteArray(getAsStream()); + // Prevent crash from OOM; we've seen this, but only rarely and not reproducibly + if (bytes.length > ImapResponseParser.LITERAL_KEEP_IN_MEMORY_THRESHOLD) { + throw new IOException(); + } + return Utility.fromAscii(bytes); + } catch (IOException e) { + Log.w(Logging.LOG_TAG, "ImapTempFileLiteral: Error while reading temp file", e); + return ""; + } + } + + @Override + public void destroy() { + try { + if (!isDestroyed() && mFile.exists()) { + mFile.delete(); + } + } catch (RuntimeException re) { + // Just log and ignore. + Log.w(Logging.LOG_TAG, "Failed to remove temp file: " + re.getMessage()); + } + super.destroy(); + } + + @Override + public String toString() { + return String.format("{%d byte literal(file)}", mSize); + } + + public boolean tempFileExistsForTest() { + return mFile.exists(); + } +} diff --git a/src/com/android/email/service/LegacyImap2AuthenticatorService.java b/src/com/android/email/service/LegacyImapAuthenticatorService.java similarity index 90% rename from src/com/android/email/service/LegacyImap2AuthenticatorService.java rename to src/com/android/email/service/LegacyImapAuthenticatorService.java index fb6dbd81f..8480d1e8d 100644 --- a/src/com/android/email/service/LegacyImap2AuthenticatorService.java +++ b/src/com/android/email/service/LegacyImapAuthenticatorService.java @@ -19,5 +19,5 @@ package com.android.email.service; /** * This service needs to be declared separately from the base service */ -public class LegacyImap2AuthenticatorService extends AuthenticatorService { +public class LegacyImapAuthenticatorService extends AuthenticatorService { } diff --git a/src/com/android/email/service/LegacyImapSyncAdapterService.java b/src/com/android/email/service/LegacyImapSyncAdapterService.java new file mode 100644 index 000000000..1f6b6195e --- /dev/null +++ b/src/com/android/email/service/LegacyImapSyncAdapterService.java @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.service; + +public class LegacyImapSyncAdapterService extends PopImapSyncAdapterService { +} \ No newline at end of file diff --git a/src/com/android/email/service/Pop3SyncAdapterService.java b/src/com/android/email/service/Pop3SyncAdapterService.java index 8f2cc8559..a939f41f9 100644 --- a/src/com/android/email/service/Pop3SyncAdapterService.java +++ b/src/com/android/email/service/Pop3SyncAdapterService.java @@ -16,212 +16,5 @@ package com.android.email.service; -import android.accounts.OperationCanceledException; -import android.app.Service; -import android.content.AbstractThreadedSyncAdapter; -import android.content.ContentProviderClient; -import android.content.ContentResolver; -import android.content.ContentUris; -import android.content.ContentValues; -import android.content.Context; -import android.content.Intent; -import android.content.SyncResult; -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.os.IBinder; -import android.util.Log; - -import com.android.emailcommon.TempDirectory; -import com.android.emailcommon.mail.MessagingException; -import com.android.emailcommon.provider.Account; -import com.android.emailcommon.provider.EmailContent; -import com.android.emailcommon.provider.EmailContent.AccountColumns; -import com.android.emailcommon.provider.EmailContent.Message; -import com.android.emailcommon.provider.Mailbox; -import com.android.emailcommon.service.EmailServiceProxy; - -import java.util.ArrayList; - -public class Pop3SyncAdapterService extends Service { - private static final String TAG = "Pop3SyncAdapterService"; - private static SyncAdapterImpl sSyncAdapter = null; - private static final Object sSyncAdapterLock = new Object(); - - public Pop3SyncAdapterService() { - super(); - } - - private static class SyncAdapterImpl extends AbstractThreadedSyncAdapter { - private Context mContext; - - public SyncAdapterImpl(Context context) { - super(context, true /* autoInitialize */); - mContext = context; - } - - @Override - public void onPerformSync(android.accounts.Account account, Bundle extras, - String authority, ContentProviderClient provider, SyncResult syncResult) { - try { - Pop3SyncAdapterService.performSync(mContext, account, extras, - authority, provider, syncResult); - } catch (OperationCanceledException e) { - } - } - } - - @Override - public void onCreate() { - super.onCreate(); - synchronized (sSyncAdapterLock) { - if (sSyncAdapter == null) { - sSyncAdapter = new SyncAdapterImpl(getApplicationContext()); - } - } - } - - @Override - public IBinder onBind(Intent intent) { - return sSyncAdapter.getSyncAdapterBinder(); - } - - private static void sync(Context context, long mailboxId, SyncResult syncResult, - boolean uiRefresh) { - TempDirectory.setTempDirectory(context); - Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); - if (mailbox == null) return; - Account account = Account.restoreAccountWithId(context, mailbox.mAccountKey); - if (account == null) return; - ContentResolver resolver = context.getContentResolver(); - if ((mailbox.mType != Mailbox.TYPE_OUTBOX) && (mailbox.mType != Mailbox.TYPE_INBOX)) { - // This is an update to a message in a non-syncing mailbox; delete this from the - // updates table and return - resolver.delete(Message.UPDATED_CONTENT_URI, Message.MAILBOX_KEY + "=?", - new String[] {Long.toString(mailbox.mId)}); - return; - } - Log.d(TAG, "Mailbox: " + mailbox.mDisplayName); - - Uri mailboxUri = ContentUris.withAppendedId(Mailbox.CONTENT_URI, mailboxId); - ContentValues values = new ContentValues(); - // Set mailbox sync state - values.put(Mailbox.UI_SYNC_STATUS, - uiRefresh ? EmailContent.SYNC_STATUS_USER : EmailContent.SYNC_STATUS_BACKGROUND); - resolver.update(mailboxUri, values, null, null); - try { - try { - if (mailbox.mType == Mailbox.TYPE_OUTBOX) { - EmailServiceStub.sendMailImpl(context, account.mId); - } else { - Pop3Service.synchronizeMailboxSynchronous(context, account, mailbox); - } - } catch (MessagingException e) { - int cause = e.getExceptionType(); - switch(cause) { - case MessagingException.IOERROR: - syncResult.stats.numIoExceptions++; - break; - case MessagingException.AUTHENTICATION_FAILED: - syncResult.stats.numAuthExceptions++; - break; - } - } - } finally { - // Always clear our sync state - values.put(Mailbox.UI_SYNC_STATUS, EmailContent.SYNC_STATUS_NONE); - resolver.update(mailboxUri, values, null, null); - } - } - - /** - * Partial integration with system SyncManager; we initiate manual syncs upon request - */ - private static void performSync(Context context, android.accounts.Account account, - Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) - throws OperationCanceledException { - // Find an EmailProvider account with the Account's email address - Cursor c = null; - try { - c = provider.query(com.android.emailcommon.provider.Account.CONTENT_URI, - Account.CONTENT_PROJECTION, AccountColumns.EMAIL_ADDRESS + "=?", - new String[] {account.name}, null); - if (c != null && c.moveToNext()) { - Account acct = new Account(); - acct.restore(c); - if (extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD)) { - Log.d(TAG, "Upload sync request for " + acct.mDisplayName); - // See if any boxes have mail... - ArrayList mailboxesToUpdate; - Cursor updatesCursor = provider.query(Message.UPDATED_CONTENT_URI, - new String[] {Message.MAILBOX_KEY}, - Message.ACCOUNT_KEY + "=?", - new String[] {Long.toString(acct.mId)}, - null); - try { - if ((updatesCursor == null) || (updatesCursor.getCount() == 0)) return; - mailboxesToUpdate = new ArrayList(); - while (updatesCursor.moveToNext()) { - Long mailboxId = updatesCursor.getLong(0); - if (!mailboxesToUpdate.contains(mailboxId)) { - mailboxesToUpdate.add(mailboxId); - } - } - } finally { - if (updatesCursor != null) { - updatesCursor.close(); - } - } - for (long mailboxId: mailboxesToUpdate) { - sync(context, mailboxId, syncResult, false); - } - } else { - Log.d(TAG, "Sync request for " + acct.mDisplayName); - Log.d(TAG, extras.toString()); - long mailboxId = extras.getLong(EmailServiceStub.SYNC_EXTRA_MAILBOX_ID, - Mailbox.NO_MAILBOX); - boolean isInbox = false; - if (mailboxId == Mailbox.NO_MAILBOX) { - mailboxId = Mailbox.findMailboxOfType(context, acct.mId, - Mailbox.TYPE_INBOX); - if (mailboxId == Mailbox.NO_MAILBOX) { - // Update folders? - EmailServiceProxy service = - EmailServiceUtils.getServiceForAccount(context, null, acct.mId); - service.updateFolderList(acct.mId); - } - isInbox = true; - } - if (mailboxId == Mailbox.NO_MAILBOX) return; - boolean uiRefresh = - extras.getBoolean(ContentResolver.SYNC_EXTRAS_FORCE, false); - sync(context, mailboxId, syncResult, uiRefresh); - - // Outbox is a special case here - Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); - if (mailbox.mType == Mailbox.TYPE_OUTBOX) { - return; - } - - // Convert from minutes to seconds - int syncFrequency = acct.mSyncInterval * 60; - // Values < 0 are for "never" or "push"; 0 is undefined - if (syncFrequency <= 0) return; - Bundle ex = new Bundle(); - if (!isInbox) { - ex.putLong(EmailServiceStub.SYNC_EXTRA_MAILBOX_ID, mailboxId); - } - Log.d(TAG, "Setting periodic sync for " + acct.mDisplayName + ": " + - syncFrequency + " seconds"); - ContentResolver.addPeriodicSync(account, authority, ex, syncFrequency); - } - } - } catch (Exception e) { - e.printStackTrace(); - } finally { - if (c != null) { - c.close(); - } - } - } +public class Pop3SyncAdapterService extends PopImapSyncAdapterService { } \ No newline at end of file diff --git a/src/com/android/email/service/PopImapSyncAdapterService.java b/src/com/android/email/service/PopImapSyncAdapterService.java new file mode 100644 index 000000000..387adb7d0 --- /dev/null +++ b/src/com/android/email/service/PopImapSyncAdapterService.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.service; + +import android.accounts.OperationCanceledException; +import android.app.Service; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.SyncResult; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; + +import com.android.email.R; +import com.android.emailcommon.TempDirectory; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.provider.Account; +import com.android.emailcommon.provider.EmailContent; +import com.android.emailcommon.provider.EmailContent.AccountColumns; +import com.android.emailcommon.provider.EmailContent.Message; +import com.android.emailcommon.provider.HostAuth; +import com.android.emailcommon.provider.Mailbox; +import com.android.emailcommon.service.EmailServiceProxy; + +import java.util.ArrayList; + +public class PopImapSyncAdapterService extends Service { + private static final String TAG = "PopImapSyncAdapterService"; + private SyncAdapterImpl mSyncAdapter = null; + private static final Object sSyncAdapterLock = new Object(); + + public PopImapSyncAdapterService() { + super(); + } + + private static class SyncAdapterImpl extends AbstractThreadedSyncAdapter { + private Context mContext; + + public SyncAdapterImpl(Context context) { + super(context, true /* autoInitialize */); + mContext = context; + } + + @Override + public void onPerformSync(android.accounts.Account account, Bundle extras, + String authority, ContentProviderClient provider, SyncResult syncResult) { + try { + PopImapSyncAdapterService.performSync(mContext, account, extras, + authority, provider, syncResult); + } catch (OperationCanceledException e) { + } + } + } + + @Override + public void onCreate() { + super.onCreate(); + synchronized (sSyncAdapterLock) { + mSyncAdapter = new SyncAdapterImpl(getApplicationContext()); + } + } + + @Override + public IBinder onBind(Intent intent) { + return mSyncAdapter.getSyncAdapterBinder(); + } + + /** + * @return whether or not this mailbox retrieves its data from the server (as opposed to just + * a local mailbox that is never synced). + */ + private static boolean loadsFromServer(Context context, Mailbox m, String protocol) { + String legacyImapProtocol = context.getString(R.string.protocol_legacy_imap); + if (legacyImapProtocol.equals(protocol)) { + // TODO: actually use a sync flag when creating the mailboxes. Right now we use an + // approximation for IMAP. + return m.mType != Mailbox.TYPE_DRAFTS + && m.mType != Mailbox.TYPE_OUTBOX + && m.mType != Mailbox.TYPE_SEARCH; + + } else if (HostAuth.LEGACY_SCHEME_POP3.equals(protocol)) { + return Mailbox.TYPE_INBOX == m.mType; + } + + return false; + } + + private static void sync(Context context, long mailboxId, SyncResult syncResult, + boolean uiRefresh) { + TempDirectory.setTempDirectory(context); + Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); + if (mailbox == null) return; + Account account = Account.restoreAccountWithId(context, mailbox.mAccountKey); + if (account == null) return; + ContentResolver resolver = context.getContentResolver(); + String protocol = account.getProtocol(context); + if ((mailbox.mType != Mailbox.TYPE_OUTBOX) && + !loadsFromServer(context, mailbox, protocol)) { + // This is an update to a message in a non-syncing mailbox; delete this from the + // updates table and return + resolver.delete(Message.UPDATED_CONTENT_URI, Message.MAILBOX_KEY + "=?", + new String[] {Long.toString(mailbox.mId)}); + return; + } + Log.d(TAG, "Mailbox: " + mailbox.mDisplayName); + + Uri mailboxUri = ContentUris.withAppendedId(Mailbox.CONTENT_URI, mailboxId); + ContentValues values = new ContentValues(); + // Set mailbox sync state + values.put(Mailbox.UI_SYNC_STATUS, + uiRefresh ? EmailContent.SYNC_STATUS_USER : EmailContent.SYNC_STATUS_BACKGROUND); + resolver.update(mailboxUri, values, null, null); + try { + try { + String legacyImapProtocol = context.getString(R.string.protocol_legacy_imap); + if (mailbox.mType == Mailbox.TYPE_OUTBOX) { + EmailServiceStub.sendMailImpl(context, account.mId); + } else if (protocol.equals(legacyImapProtocol)) { + ImapService.synchronizeMailboxSynchronous(context, account, mailbox); + } else { + Pop3Service.synchronizeMailboxSynchronous(context, account, mailbox); + } + } catch (MessagingException e) { + int cause = e.getExceptionType(); + switch(cause) { + case MessagingException.IOERROR: + syncResult.stats.numIoExceptions++; + break; + case MessagingException.AUTHENTICATION_FAILED: + syncResult.stats.numAuthExceptions++; + break; + } + } + } finally { + // Always clear our sync state + values.put(Mailbox.UI_SYNC_STATUS, EmailContent.SYNC_STATUS_NONE); + resolver.update(mailboxUri, values, null, null); + } + } + + /** + * Partial integration with system SyncManager; we initiate manual syncs upon request + */ + private static void performSync(Context context, android.accounts.Account account, + Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) + throws OperationCanceledException { + // Find an EmailProvider account with the Account's email address + Cursor c = null; + try { + c = provider.query(com.android.emailcommon.provider.Account.CONTENT_URI, + Account.CONTENT_PROJECTION, AccountColumns.EMAIL_ADDRESS + "=?", + new String[] {account.name}, null); + if (c != null && c.moveToNext()) { + Account acct = new Account(); + acct.restore(c); + if (extras.getBoolean(ContentResolver.SYNC_EXTRAS_UPLOAD)) { + Log.d(TAG, "Upload sync request for " + acct.mDisplayName); + // See if any boxes have mail... + ArrayList mailboxesToUpdate; + Cursor updatesCursor = provider.query(Message.UPDATED_CONTENT_URI, + new String[] {Message.MAILBOX_KEY}, + Message.ACCOUNT_KEY + "=?", + new String[] {Long.toString(acct.mId)}, + null); + try { + if ((updatesCursor == null) || (updatesCursor.getCount() == 0)) return; + mailboxesToUpdate = new ArrayList(); + while (updatesCursor.moveToNext()) { + Long mailboxId = updatesCursor.getLong(0); + if (!mailboxesToUpdate.contains(mailboxId)) { + mailboxesToUpdate.add(mailboxId); + } + } + } finally { + if (updatesCursor != null) { + updatesCursor.close(); + } + } + for (long mailboxId: mailboxesToUpdate) { + sync(context, mailboxId, syncResult, false); + } + } else { + Log.d(TAG, "Sync request for " + acct.mDisplayName); + Log.d(TAG, extras.toString()); + long mailboxId = extras.getLong(EmailServiceStub.SYNC_EXTRA_MAILBOX_ID, + Mailbox.NO_MAILBOX); + boolean isInbox = false; + if (mailboxId == Mailbox.NO_MAILBOX) { + mailboxId = Mailbox.findMailboxOfType(context, acct.mId, + Mailbox.TYPE_INBOX); + if (mailboxId == Mailbox.NO_MAILBOX) { + // Update folders? + EmailServiceProxy service = + EmailServiceUtils.getServiceForAccount(context, null, acct.mId); + service.updateFolderList(acct.mId); + } + isInbox = true; + } + if (mailboxId == Mailbox.NO_MAILBOX) return; + boolean uiRefresh = + extras.getBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, false); + sync(context, mailboxId, syncResult, uiRefresh); + + // Outbox is a special case here + Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); + if (mailbox.mType == Mailbox.TYPE_OUTBOX) { + return; + } + + // Convert from minutes to seconds + int syncFrequency = acct.mSyncInterval * 60; + // Values < 0 are for "never" or "push"; 0 is undefined + if (syncFrequency <= 0) return; + Bundle ex = new Bundle(); + if (!isInbox) { + ex.putLong(EmailServiceStub.SYNC_EXTRA_MAILBOX_ID, mailboxId); + } + Log.d(TAG, "Setting periodic sync for " + acct.mDisplayName + ": " + + syncFrequency + " seconds"); + ContentResolver.addPeriodicSync(account, authority, ex, syncFrequency); + } + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (c != null) { + c.close(); + } + } + } +} \ No newline at end of file