diff options
author | Nancy Chen <nancychen@google.com> | 2015-03-19 16:28:55 -0700 |
---|---|---|
committer | Nancy Chen <nancychen@google.com> | 2015-03-25 12:49:57 -0700 |
commit | 98750f5774070527403035d1713ea59abd71ec08 (patch) | |
tree | 9286ec4e2ea1225e3b087d9aaab5d3a1f058fcaa /src | |
parent | e36994bef9400604210d81c98598cab7c256a6f6 (diff) | |
download | android_packages_apps_PhoneCommon-98750f5774070527403035d1713ea59abd71ec08.tar.gz android_packages_apps_PhoneCommon-98750f5774070527403035d1713ea59abd71ec08.tar.bz2 android_packages_apps_PhoneCommon-98750f5774070527403035d1713ea59abd71ec08.zip |
Imap client implementation classes and helpers.
Direct interaction
- ImapConnection, ImapFolder, ImapStore, MailTransport
- ImapStore holds the credentials, ImapFolder handles folder-specific
actions, ImapConnection sets up the connection to the server,
MailTransport makes the connection and submits the requested actions.
Bug 19236241
Change-Id: Id492353899ec04598735408e45ecf79b17154ea8
Diffstat (limited to 'src')
20 files changed, 2779 insertions, 0 deletions
diff --git a/src/com/android/phone/common/mail/AuthenticationFailedException.java b/src/com/android/phone/common/mail/AuthenticationFailedException.java new file mode 100644 index 0000000..f13ab45 --- /dev/null +++ b/src/com/android/phone/common/mail/AuthenticationFailedException.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2015 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.phone.common.mail; + +public class AuthenticationFailedException extends MessagingException { + public static final long serialVersionUID = -1; + + public AuthenticationFailedException(String message) { + super(MessagingException.AUTHENTICATION_FAILED, message); + } + + public AuthenticationFailedException(int exceptionType, String message) { + super(exceptionType, message); + } + + public AuthenticationFailedException(String message, Throwable throwable) { + super(MessagingException.AUTHENTICATION_FAILED, message, throwable); + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/CertificateValidationException.java b/src/com/android/phone/common/mail/CertificateValidationException.java new file mode 100644 index 0000000..d8ae9b4 --- /dev/null +++ b/src/com/android/phone/common/mail/CertificateValidationException.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2015 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.phone.common.mail; + +public class CertificateValidationException extends MessagingException { + public static final long serialVersionUID = -1; + + public CertificateValidationException(String message) { + super(MessagingException.CERTIFICATE_VALIDATION_ERROR, message); + } + + public CertificateValidationException(String message, Throwable throwable) { + super(MessagingException.CERTIFICATE_VALIDATION_ERROR, message, throwable); + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/FixedLengthInputStream.java b/src/com/android/phone/common/mail/FixedLengthInputStream.java new file mode 100644 index 0000000..499feca --- /dev/null +++ b/src/com/android/phone/common/mail/FixedLengthInputStream.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2015 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.phone.common.mail; + +import java.io.IOException; +import java.io.InputStream; + +/** + * A filtering InputStream that stops allowing reads after the given length has been read. This + * is used to allow a client to read directly from an underlying protocol stream without reading + * past where the protocol handler intended the client to read. + */ +public class FixedLengthInputStream extends InputStream { + private final InputStream mIn; + private final int mLength; + private int mCount; + + public FixedLengthInputStream(InputStream in, int length) { + this.mIn = in; + this.mLength = length; + } + + @Override + public int available() throws IOException { + return mLength - mCount; + } + + @Override + public int read() throws IOException { + if (mCount < mLength) { + mCount++; + return mIn.read(); + } else { + return -1; + } + } + + @Override + public int read(byte[] b, int offset, int length) throws IOException { + if (mCount < mLength) { + int d = mIn.read(b, offset, Math.min(mLength - mCount, length)); + if (d == -1) { + return -1; + } else { + mCount += d; + return d; + } + } else { + return -1; + } + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + public int getLength() { + return mLength; + } + + @Override + public String toString() { + return String.format("FixedLengthInputStream(in=%s, length=%d)", mIn.toString(), mLength); + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/MailTransport.java b/src/com/android/phone/common/mail/MailTransport.java new file mode 100644 index 0000000..5e3f7f5 --- /dev/null +++ b/src/com/android/phone/common/mail/MailTransport.java @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2015 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.phone.common.mail; + +import android.content.Context; +import android.util.Log; + +import com.android.phone.common.mail.store.ImapStore; + +import java.net.SocketAddress; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; + +/** + * Make connection and perform operations on mail server by reading and writing lines. + */ +public class MailTransport { + private static final String TAG = "MailTransport"; + + // TODO protected eventually + /*protected*/ public static final int SOCKET_CONNECT_TIMEOUT = 10000; + /*protected*/ public static final int SOCKET_READ_TIMEOUT = 60000; + + private static final HostnameVerifier HOSTNAME_VERIFIER = + HttpsURLConnection.getDefaultHostnameVerifier(); + + private Context mContext; + private String mHost; + private int mPort; + private Socket mSocket; + private BufferedInputStream mIn; + private BufferedOutputStream mOut; + private int mFlags; + + public MailTransport(Context context, String address, int port, int flags) { + mContext = context; + mHost = address; + mPort = port; + mFlags = flags; + } + + /** + * Returns a new transport, using the current transport as a model. The new transport is + * configured identically, but not opened or connected in any way. + */ + @Override + public MailTransport clone() { + return new MailTransport(mContext, mHost, mPort, mFlags); + } + + public boolean canTrySslSecurity() { + return (mFlags & ImapStore.FLAG_SSL) != 0; + } + + public boolean canTrustAllCertificates() { + return (mFlags & ImapStore.FLAG_TRUST_ALL) != 0; + } + + /** + * Attempts to open a connection using the Uri supplied for connection parameters. Will attempt + * an SSL connection if indicated. + */ + public void open() throws MessagingException, CertificateValidationException { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "*** IMAP open " + mHost + ":" + String.valueOf(mPort)); + } + + try { + SocketAddress socketAddress = new InetSocketAddress(mHost, mPort); + if (canTrySslSecurity()) { + mSocket = HttpsURLConnection.getDefaultSSLSocketFactory().createSocket(); + } else { + mSocket = new Socket(); + } + mSocket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); + // After the socket connects to an SSL server, confirm that the hostname is as expected + if (canTrySslSecurity() && !canTrustAllCertificates()) { + verifyHostname(mSocket, mHost); + } + + mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); + mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); + mSocket.setSoTimeout(SOCKET_READ_TIMEOUT); + } catch (SSLException e) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, e.toString()); + } + throw new CertificateValidationException(e.getMessage(), e); + } catch (IOException ioe) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, ioe.toString()); + } + throw new MessagingException(MessagingException.IOERROR, ioe.toString()); + } catch (IllegalArgumentException iae) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, iae.toString()); + } + throw new MessagingException(MessagingException.UNSPECIFIED_EXCEPTION, iae.toString()); + } + } + + /** + * Lightweight version of SSLCertificateSocketFactory.verifyHostname, which provides this + * service but is not in the public API. + * + * Verify the hostname of the certificate used by the other end of a + * connected socket. It is harmless to call this method redundantly if the hostname has already + * been verified. + * + * <p>Wildcard certificates are allowed to verify any matching hostname, + * so "foo.bar.example.com" is verified if the peer has a certificate + * for "*.example.com". + * + * @param socket An SSL socket which has been connected to a server + * @param hostname The expected hostname of the remote server + * @throws IOException if something goes wrong handshaking with the server + * @throws SSLPeerUnverifiedException if the server cannot prove its identity + */ + private static void verifyHostname(Socket socket, String hostname) throws IOException { + // The code at the start of OpenSSLSocketImpl.startHandshake() + // ensures that the call is idempotent, so we can safely call it. + SSLSocket ssl = (SSLSocket) socket; + ssl.startHandshake(); + + SSLSession session = ssl.getSession(); + if (session == null) { + throw new SSLException("Cannot verify SSL socket without session"); + } + // TODO: Instead of reporting the name of the server we think we're connecting to, + // we should be reporting the bad name in the certificate. Unfortunately this is buried + // in the verifier code and is not available in the verifier API, and extracting the + // CN & alts is beyond the scope of this patch. + if (!HOSTNAME_VERIFIER.verify(hostname, session)) { + throw new SSLPeerUnverifiedException( + "Certificate hostname not useable for server: " + hostname); + } + } + + public boolean isOpen() { + return (mIn != null && mOut != null && + mSocket != null && mSocket.isConnected() && !mSocket.isClosed()); + } + + /** + * Close the connection. MUST NOT return any exceptions - must be "best effort" and safe. + */ + public void close() { + try { + mIn.close(); + } catch (Exception e) { + // May fail if the connection is already closed. + } + try { + mOut.close(); + } catch (Exception e) { + // May fail if the connection is already closed. + } + try { + mSocket.close(); + } catch (Exception e) { + // May fail if the connection is already closed. + } + mIn = null; + mOut = null; + mSocket = null; + } + + public InputStream getInputStream() { + return mIn; + } + + public OutputStream getOutputStream() { + return mOut; + } + + /** + * Writes a single line to the server using \r\n termination. + */ + public void writeLine(String s, String sensitiveReplacement) throws IOException { + if (Log.isLoggable(TAG, Log.DEBUG)) { + if (sensitiveReplacement != null) { + Log.d(TAG, ">>> " + sensitiveReplacement); + } else { + Log.d(TAG, ">>> " + s); + } + } + + OutputStream out = getOutputStream(); + out.write(s.getBytes()); + out.write('\r'); + out.write('\n'); + out.flush(); + } + + /** + * Reads a single line from the server, using either \r\n or \n as the delimiter. The + * delimiter char(s) are not included in the result. + */ + public String readLine(boolean loggable) throws IOException { + StringBuffer sb = new StringBuffer(); + InputStream in = getInputStream(); + int d; + while ((d = in.read()) != -1) { + if (((char)d) == '\r') { + continue; + } else if (((char)d) == '\n') { + break; + } else { + sb.append((char)d); + } + } + if (d == -1 && Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "End of stream reached while trying to read line."); + } + String ret = sb.toString(); + if (loggable && Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "<<< " + ret); + } + return ret; + } +} diff --git a/src/com/android/phone/common/mail/Message.java b/src/com/android/phone/common/mail/Message.java new file mode 100644 index 0000000..cd5a44d --- /dev/null +++ b/src/com/android/phone/common/mail/Message.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2015 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.phone.common.mail; + +/** + * Object to represent an email message. + */ +public class Message { + public static final String FLAG_SEEN = "seen"; + public static final String FLAG_DELETED = "deleted"; +} diff --git a/src/com/android/phone/common/mail/MessagingException.java b/src/com/android/phone/common/mail/MessagingException.java new file mode 100644 index 0000000..e4c674a --- /dev/null +++ b/src/com/android/phone/common/mail/MessagingException.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2015 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.phone.common.mail; + +/** + * This exception is used for most types of failures that occur during server interactions. + * + * Data passed through this exception should be considered non-localized. Any strings should + * either be internal-only (for debugging) or server-generated. + * + * TO DO: Does it make sense to further collapse AuthenticationFailedException and + * CertificateValidationException and any others into this? + */ +public class MessagingException extends Exception { + public static final long serialVersionUID = -1; + + public static final int NO_ERROR = -1; + /** Any exception that does not specify a specific issue */ + public static final int UNSPECIFIED_EXCEPTION = 0; + /** Connection or IO errors */ + public static final int IOERROR = 1; + /** The configuration requested TLS but the server did not support it. */ + public static final int TLS_REQUIRED = 2; + /** Authentication is required but the server did not support it. */ + public static final int AUTH_REQUIRED = 3; + /** General security failures */ + public static final int GENERAL_SECURITY = 4; + /** Authentication failed */ + public static final int AUTHENTICATION_FAILED = 5; + /** Attempt to create duplicate account */ + public static final int DUPLICATE_ACCOUNT = 6; + /** Required security policies reported - advisory only */ + public static final int SECURITY_POLICIES_REQUIRED = 7; + /** Required security policies not supported */ + public static final int SECURITY_POLICIES_UNSUPPORTED = 8; + /** The protocol (or protocol version) isn't supported */ + public static final int PROTOCOL_VERSION_UNSUPPORTED = 9; + /** The server's SSL certificate couldn't be validated */ + public static final int CERTIFICATE_VALIDATION_ERROR = 10; + /** Authentication failed during autodiscover */ + public static final int AUTODISCOVER_AUTHENTICATION_FAILED = 11; + /** Autodiscover completed with a result (non-error) */ + public static final int AUTODISCOVER_AUTHENTICATION_RESULT = 12; + /** Ambiguous failure; server error or bad credentials */ + public static final int AUTHENTICATION_FAILED_OR_SERVER_ERROR = 13; + /** The server refused access */ + public static final int ACCESS_DENIED = 14; + /** The server refused access */ + public static final int ATTACHMENT_NOT_FOUND = 15; + /** A client SSL certificate is required for connections to the server */ + public static final int CLIENT_CERTIFICATE_REQUIRED = 16; + /** The client SSL certificate specified is invalid */ + public static final int CLIENT_CERTIFICATE_ERROR = 17; + /** The server indicates it does not support OAuth authentication */ + public static final int OAUTH_NOT_SUPPORTED = 18; + /** The server indicates it experienced an internal error */ + public static final int SERVER_ERROR = 19; + + protected int mExceptionType; + // Exception type-specific data + protected Object mExceptionData; + + public MessagingException(String message, Throwable throwable) { + this(UNSPECIFIED_EXCEPTION, message, throwable); + } + + public MessagingException(int exceptionType, String message, Throwable throwable) { + super(message, throwable); + mExceptionType = exceptionType; + mExceptionData = null; + } + + /** + * Constructs a MessagingException with an exceptionType and a null message. + * @param exceptionType The exception type to set for this exception. + */ + public MessagingException(int exceptionType) { + this(exceptionType, null, null); + } + + /** + * Constructs a MessagingException with a message. + * @param message the message for this exception + */ + public MessagingException(String message) { + this(UNSPECIFIED_EXCEPTION, message, null); + } + + /** + * Constructs a MessagingException with an exceptionType and a message. + * @param exceptionType The exception type to set for this exception. + */ + public MessagingException(int exceptionType, String message) { + this(exceptionType, message, null); + } + + /** + * Constructs a MessagingException with an exceptionType, a message, and data + * @param exceptionType The exception type to set for this exception. + * @param message the message for the exception (or null) + * @param data exception-type specific data for the exception (or null) + */ + public MessagingException(int exceptionType, String message, Object data) { + super(message); + mExceptionType = exceptionType; + mExceptionData = data; + } + + /** + * Return the exception type. Will be OTHER_EXCEPTION if not explicitly set. + * + * @return Returns the exception type. + */ + public int getExceptionType() { + return mExceptionType; + } + /** + * Return the exception data. Will be null if not explicitly set. + * + * @return Returns the exception data. + */ + public Object getExceptionData() { + return mExceptionData; + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/PeekableInputStream.java b/src/com/android/phone/common/mail/PeekableInputStream.java new file mode 100644 index 0000000..d29fc6b --- /dev/null +++ b/src/com/android/phone/common/mail/PeekableInputStream.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2015 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.phone.common.mail; + +import java.io.IOException; +import java.io.InputStream; + +/** + * A filtering InputStream that allows single byte "peeks" without consuming the byte. The + * client of this stream can call peek() to see the next available byte in the stream + * and a subsequent read will still return the peeked byte. + */ +public class PeekableInputStream extends InputStream { + private final InputStream mIn; + private boolean mPeeked; + private int mPeekedByte; + + public PeekableInputStream(InputStream in) { + this.mIn = in; + } + + @Override + public int read() throws IOException { + if (!mPeeked) { + return mIn.read(); + } else { + mPeeked = false; + return mPeekedByte; + } + } + + public int peek() throws IOException { + if (!mPeeked) { + mPeekedByte = read(); + mPeeked = true; + } + return mPeekedByte; + } + + @Override + public int read(byte[] b, int offset, int length) throws IOException { + if (!mPeeked) { + return mIn.read(b, offset, length); + } else { + b[0] = (byte)mPeekedByte; + mPeeked = false; + int r = mIn.read(b, offset + 1, length - 1); + if (r == -1) { + return 1; + } else { + return r + 1; + } + } + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public String toString() { + return String.format("PeekableInputStream(in=%s, peeked=%b, peekedByte=%d)", + mIn.toString(), mPeeked, mPeekedByte); + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/ImapConnection.java b/src/com/android/phone/common/mail/store/ImapConnection.java new file mode 100644 index 0000000..3cae5f8 --- /dev/null +++ b/src/com/android/phone/common/mail/store/ImapConnection.java @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store; + +import android.text.TextUtils; +import android.util.Log; + +import com.android.phone.common.mail.AuthenticationFailedException; +import com.android.phone.common.mail.CertificateValidationException; +import com.android.phone.common.mail.MailTransport; +import com.android.phone.common.mail.MessagingException; +import com.android.phone.common.mail.store.imap.ImapConstants; +import com.android.phone.common.mail.store.imap.ImapResponse; +import com.android.phone.common.mail.store.imap.ImapResponseParser; +import com.android.phone.common.mail.store.imap.ImapUtility; +import com.android.phone.common.mail.store.ImapStore.ImapException; + +import java.io.IOException; +import java.util.ArrayList; +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. + */ +public class ImapConnection { + private final String TAG = "ImapConnection"; + + private String mLoginPhrase; + private ImapStore mImapStore; + private MailTransport mTransport; + private ImapResponseParser mParser; + + static final String IMAP_REDACTED_LOG = "[IMAP command redacted]"; + + /** + * 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); + + ImapConnection(ImapStore store) { + setStore(store); + } + + void setStore(ImapStore store) { + // TODO: maybe we should throw an exception if the connection is not closed here, + // if it's not currently closed, then we won't reopen it, so if the credentials have + // changed, the connection will not be reestablished. + mImapStore = store; + mLoginPhrase = null; + } + + /** + * Generates and returns the phrase to be used for authentication. This will be a LOGIN with + * username and password. + * + * @return the login command string to sent to the IMAP server + */ + String getLoginPhrase() { + if (mLoginPhrase == null) { + if (mImapStore.getUsername() != null && mImapStore.getPassword() != null) { + // build the LOGIN string once (instead of over-and-over again.) + // apply the quoting here around the built-up password + mLoginPhrase = ImapConstants.LOGIN + " " + mImapStore.getUsername() + " " + + ImapUtility.imapQuoted(mImapStore.getPassword()); + } + } + return mLoginPhrase; + } + + 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(); + + createParser(); + + // LOGIN + doLogin(); + } catch (SSLException e) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "SSLException ", e); + } + throw new CertificateValidationException(e.getMessage(), e); + } catch (IOException ioe) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "IOException", ioe); + } + throw ioe; + } finally { + destroyResponses(); + } + } + + /** + * Closes the connection and releases all resources. This connection can not be used again + * until {@link #setStore(ImapStore)} is called. + */ + void close() { + if (mTransport != null) { + mTransport.close(); + mTransport = null; + } + destroyResponses(); + mParser = null; + mImapStore = null; + } + + /** + * Logs into the IMAP server + */ + private void doLogin() throws IOException, MessagingException, AuthenticationFailedException { + try { + executeSimpleCommand(getLoginPhrase(), true); + } catch (ImapException ie) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "ImapException", ie); + } + final String status = ie.getStatus(); + final String code = ie.getResponseCode(); + final String alertText = ie.getAlertText(); + + // if the response code indicates expired or bad credentials, throw a special exception + if (ImapConstants.AUTHENTICATIONFAILED.equals(code) || + ImapConstants.EXPIRED.equals(code) || + (ImapConstants.NO.equals(status) && TextUtils.isEmpty(code))) { + throw new AuthenticationFailedException(alertText, ie); + } + + throw new MessagingException(alertText, ie); + } + } + + /** + * 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()); + } + + + void destroyResponses() { + if (mParser != null) { + mParser.destroyResponses(); + } + } + + List<ImapResponse> executeSimpleCommand(String command) + throws IOException, MessagingException{ + return executeSimpleCommand(command, false); + } + + /** + * 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). + * 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<ImapResponse> executeSimpleCommand(String command, boolean sensitive) + throws IOException, MessagingException { + // TODO: It may be nice to catch IOExceptions and close the connection here. + // Currently, we expect callers to do that, but if they fail to we'll be in a broken state. + sendCommand(command, sensitive); + return getCommandResponses(); + } + + String sendCommand(String command, boolean sensitive) throws IOException, MessagingException { + open(); + + if (mTransport == null) { + throw new IOException("Null transport"); + } + String tag = Integer.toString(mNextCommandTag.incrementAndGet()); + String commandToSend = tag + " " + command; + mTransport.writeLine(commandToSend, (sensitive ? IMAP_REDACTED_LOG : command)); + + return tag; + } + + /** + * 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<ImapResponse> getCommandResponses() throws IOException, MessagingException { + final List<ImapResponse> responses = new ArrayList<ImapResponse>(); + ImapResponse response; + do { + response = mParser.readResponse(); + responses.add(response); + } while (!response.isTagged()); + + if (!response.isOk()) { + final String toString = response.toString(); + final String status = response.getStatusOrEmpty().getString(); + final String alert = response.getAlertTextOrEmpty().getString(); + final String responseCode = response.getResponseCodeOrEmpty().getString(); + destroyResponses(); + + // if the response code indicates an error occurred within the server, indicate that + if (ImapConstants.UNAVAILABLE.equals(responseCode)) { + throw new MessagingException(MessagingException.SERVER_ERROR, alert); + } + + throw new ImapException(toString, status, alert, responseCode); + } + return responses; + } +} diff --git a/src/com/android/phone/common/mail/store/ImapFolder.java b/src/com/android/phone/common/mail/store/ImapFolder.java new file mode 100644 index 0000000..9f7db4a --- /dev/null +++ b/src/com/android/phone/common/mail/store/ImapFolder.java @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store; + +import android.util.Log; + +import com.android.phone.common.mail.AuthenticationFailedException; +import com.android.phone.common.mail.MessagingException; +import com.android.phone.common.mail.store.imap.ImapConstants; +import com.android.phone.common.mail.store.imap.ImapResponse; +import com.android.phone.common.mail.store.imap.ImapString; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +/** + * Represents one folder on the IMAP server. + */ +public class ImapFolder { + private final String TAG = "ImapFolder"; + private ImapStore mStore; + private String mName; + private String mMode; + private boolean mExists; + private ImapConnection mConnection; + private int mMessageCount; + + public static final String MODE_READ_ONLY = "mode_read_only"; + public static final String MODE_READ_WRITE = "mode_read_write"; + + public ImapFolder(ImapStore store, String name) { + mStore = store; + mName = name; + } + + private void destroyResponses() { + if (mConnection != null) { + mConnection.destroyResponses(); + } + } + + public void open(String 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; + } + } + + public boolean isOpen() { + return mExists && mConnection != null; + } + + /** + * Selects the folder for use. Before performing any operations on this folder, it + * must be selected. + */ + private void doSelect() throws IOException, MessagingException { + final List<ImapResponse> responses = mConnection.executeSimpleCommand( + String.format(Locale.US, ImapConstants.SELECT + " \"%s\"", mName)); + + // Assume the folder is opened read-write; unless we are notified otherwise + mMode = MODE_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 = MODE_READ_ONLY; + } else if (responseCode.is(ImapConstants.READ_WRITE)) { + mMode = MODE_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; + } + + public void close(boolean expunge) { + // TODO implement expunge + mMessageCount = -1; + synchronized (this) { + mStore.closeConnection(); + mConnection = null; + } + } + + private MessagingException ioExceptionHandler(ImapConnection connection, IOException ioe) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(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(MessagingException.IOERROR, "IO Error", ioe); + } +} diff --git a/src/com/android/phone/common/mail/store/ImapStore.java b/src/com/android/phone/common/mail/store/ImapStore.java new file mode 100644 index 0000000..38bb62f --- /dev/null +++ b/src/com/android/phone/common/mail/store/ImapStore.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store; + +import android.content.Context; + +import com.android.phone.common.mail.MailTransport; +import com.android.phone.common.mail.MessagingException; +import com.android.phone.common.mail.store.ImapFolder; + +public class ImapStore { + /** + * A global suggestion to Store implementors on how much of the body + * should be returned on FetchProfile.Item.BODY_SANE requests. We'll use 125k now. + */ + public static final int FETCH_BODY_SANE_SUGGESTED_SIZE = (125 * 1024); + private Context mContext; + private String mUsername; + private String mPassword; + private MailTransport mTransport; + private ImapConnection mConnection; + + public static final int FLAG_NONE = 0x00; // No flags + public static final int FLAG_SSL = 0x01; // Use SSL + public static final int FLAG_TLS = 0x02; // Use TLS + public static final int FLAG_AUTHENTICATE = 0x04; // Use name/password for authentication + public static final int FLAG_TRUST_ALL = 0x08; // Trust all certificates + public static final int FLAG_OAUTH = 0x10; // Use OAuth for authentication + + /** + * Contains all the information necessary to log into an imap server + */ + public ImapStore(Context context, String username, String password, int port, + String serverName, int flags) { + mContext = context; + mUsername = username; + mPassword = password; + mTransport = new MailTransport(context, serverName, port, flags); + } + + public ImapFolder getFolder(String name) { + return new ImapFolder(this, name); + } + + public String getUsername() { + return mUsername; + } + + public String getPassword() { + return mPassword; + } + + /** Returns a clone of the transport associated with this store. */ + MailTransport cloneTransport() { + return mTransport.clone(); + } + + static class ImapException extends MessagingException { + private static final long serialVersionUID = 1L; + + private final String mStatus; + private final String mAlertText; + private final String mResponseCode; + + public ImapException(String message, String status, String alertText, + String responseCode) { + super(message); + mStatus = status; + mAlertText = alertText; + mResponseCode = responseCode; + } + + public String getStatus() { + return mStatus; + } + + public String getAlertText() { + return mAlertText; + } + + public String getResponseCode() { + return mResponseCode; + } + } + + public void closeConnection() { + if (mConnection != null) { + mConnection.close(); + mConnection = null; + } + } + + public ImapConnection getConnection() { + if (mConnection == null) { + mConnection = new ImapConnection(this); + } + return mConnection; + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapConstants.java b/src/com/android/phone/common/mail/store/imap/ImapConstants.java new file mode 100644 index 0000000..0f1f526 --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapConstants.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store.imap; + +import com.android.phone.common.mail.store.ImapStore; + +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>", ImapStore.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 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 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 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"; + + /** response codes within IMAP responses */ + public static final String EXPIRED = "EXPIRED"; + public static final String AUTHENTICATIONFAILED = "AUTHENTICATIONFAILED"; + public static final String UNAVAILABLE = "UNAVAILABLE"; +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapElement.java b/src/com/android/phone/common/mail/store/imap/ImapElement.java new file mode 100644 index 0000000..2d1824e --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapElement.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store.imap; + +/** + * Class representing "element"s in IMAP responses. + * + * <p>Class hierarchy: + * <pre> + * ImapElement + * | + * |-- ImapElement.NONE (for 'index out of range') + * | + * |-- ImapList (isList() == true) + * | | + * | |-- ImapList.EMPTY + * | | + * | --- ImapResponse + * | + * --- ImapString (isString() == true) + * | + * |-- ImapString.EMPTY + * | + * |-- ImapSimpleString + * | + * |-- ImapMemoryLiteral + * | + * --- ImapTempFileLiteral + * </pre> + */ +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. + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapList.java b/src/com/android/phone/common/mail/store/imap/ImapList.java new file mode 100644 index 0000000..93adc4f --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapList.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2015 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.phone.common.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<ImapElement> mList = new ArrayList<ImapElement>(); + + /* 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; + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapMemoryLiteral.java b/src/com/android/phone/common/mail/store/imap/ImapMemoryLiteral.java new file mode 100644 index 0000000..aac66c2 --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapMemoryLiteral.java @@ -0,0 +1,77 @@ +/* + * 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.phone.common.mail.store.imap; + +import android.util.Log; + +import com.android.phone.common.mail.FixedLengthInputStream; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; + +/** + * Subclass of {@link ImapString} used for literals backed by an in-memory byte array. + */ +public class ImapMemoryLiteral extends ImapString { + private final String TAG = "ImapMemoryLiteral"; + 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(TAG, ""); + } + } + + @Override + public void destroy() { + mData = null; + super.destroy(); + } + + @Override + public String getString() { + try { + return new String(mData, "US-ASCII"); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Unsupported encoding: ", e); + } + return null; + } + + @Override + public InputStream getAsStream() { + return new ByteArrayInputStream(mData); + } + + @Override + public String toString() { + return String.format("{%d byte literal(memory)}", mData.length); + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapResponse.java b/src/com/android/phone/common/mail/store/imap/ImapResponse.java new file mode 100644 index 0000000..4891966 --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapResponse.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2015 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.phone.common.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); + } + + public ImapString getStatusOrEmpty() { + if (!isStatusResponse()) { + return ImapString.EMPTY; + } + return getStringOrEmpty(0); + } + + @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; + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapResponseParser.java b/src/com/android/phone/common/mail/store/imap/ImapResponseParser.java new file mode 100644 index 0000000..1bf1565 --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapResponseParser.java @@ -0,0 +1,434 @@ +/* + * 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.phone.common.mail.store.imap; + +import android.text.TextUtils; +import android.util.Log; + +import com.android.phone.common.mail.FixedLengthInputStream; +import com.android.phone.common.mail.PeekableInputStream; +import com.android.phone.common.mail.MessagingException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; + +/** + * IMAP response parser. + */ +public class ImapResponseParser { + private static final String TAG = "ImapResponseParser"; + + /** + * 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; + + 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<ImapResponse> mResponsesToDestroy = new ArrayList<ImapResponse>(); + + /** + * 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) { + this(in, LITERAL_KEEP_IN_MEMORY_THRESHOLD); + } + + /** + * Constructor for testing to override the literal size threshold. + */ + /* package for test */ ImapResponseParser(InputStream in, int literalKeepInMemoryThreshold) { + mIn = new PeekableInputStream(in); + mLiteralKeepInMemoryThreshold = literalKeepInMemoryThreshold; + } + + private static IOException newEOSException() { + final String message = "End of stream reached"; + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(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(); + } + 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. + * + * <p>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 (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(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(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(TAG, "Exception detected: " + e.getMessage()); + } + + /** + * 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"); + } + if (size < 0) { + throw new MessagingException("Invalid negative 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); + } + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapSimpleString.java b/src/com/android/phone/common/mail/store/imap/ImapSimpleString.java new file mode 100644 index 0000000..3d5263b --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapSimpleString.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store.imap; + +import android.util.Log; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; + +/** + * Subclass of {@link ImapString} used for non literals. + */ +public class ImapSimpleString extends ImapString { + private final String TAG = "ImapSimpleString"; + 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() { + try { + return new ByteArrayInputStream(mString.getBytes("US-ASCII")); + } catch (UnsupportedEncodingException e) { + Log.e(TAG, "Unsupported encoding: ", e); + } + return null; + } + + @Override + public String toString() { + // Purposefully not return just mString, in order to prevent using it instead of getString. + return "\"" + mString + "\""; + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapString.java b/src/com/android/phone/common/mail/store/imap/ImapString.java new file mode 100644 index 0000000..f38a993 --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapString.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store.imap; + +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("ImapString", 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()); + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapTempFileLiteral.java b/src/com/android/phone/common/mail/store/imap/ImapTempFileLiteral.java new file mode 100644 index 0000000..2b1113e --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapTempFileLiteral.java @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store.imap; + +import android.content.Context; +import android.util.Log; + +import com.android.phone.common.mail.FixedLengthInputStream; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +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 { + private final String TAG = "ImapTempFileLiteral"; + + /* 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); + StreamUtils.copy(stream, out); + out.close(); + } + + /** + * Copies utility methods for working with byte arrays and I/O streams from guava library. + */ + public static class StreamUtils { + private static final int BUF_SIZE = 0x1000; // 4K + /** + * Copies all bytes from the input stream to the output stream. + * Does not close or flush either stream. + * + * @param from the input stream to read from + * @param to the output stream to write to + * @return the number of bytes copied + * @throws IOException if an I/O error occurs + */ + public static long copy(InputStream from, OutputStream to) throws IOException { + checkNotNull(from); + checkNotNull(to); + byte[] buf = new byte[BUF_SIZE]; + long total = 0; + while (true) { + int r = from.read(buf); + if (r == -1) { + break; + } + to.write(buf, 0, r); + total += r; + } + return total; + } + + /** + * Reads all bytes from an input stream into a byte array. + * Does not close the stream. + * + * @param in the input stream to read from + * @return a byte array containing all the bytes from the stream + * @throws IOException if an I/O error occurs + */ + public static byte[] toByteArray(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + copy(in, out); + return out.toByteArray(); + } + + /** + * Ensures that an object reference passed as a parameter to the calling method is not null. + * + * @param reference an object reference + * @return the non-null reference that was validated + * @throws NullPointerException if {@code reference} is null + */ + public static <T> T checkNotNull(T reference) { + if (reference == null) { + throw new NullPointerException(); + } + return reference; + } + } + + /** + * 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(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 = StreamUtils.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 new String(bytes, "US-ASCII"); + } catch (IOException e) { + Log.w(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(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(); + } + + /** + * TempDirectory caches the directory used for caching file. It is set up during application + * initialization. + */ + public static class TempDirectory { + private static File sTempDirectory = null; + + public static void setTempDirectory(Context context) { + sTempDirectory = context.getCacheDir(); + } + + public static File getTempDirectory() { + if (sTempDirectory == null) { + throw new RuntimeException( + "TempDirectory not set. " + + "If in a unit test, call Email.setTempDirectory(context) in setUp()."); + } + return sTempDirectory; + } + } +}
\ No newline at end of file diff --git a/src/com/android/phone/common/mail/store/imap/ImapUtility.java b/src/com/android/phone/common/mail/store/imap/ImapUtility.java new file mode 100644 index 0000000..28c8b6f --- /dev/null +++ b/src/com/android/phone/common/mail/store/imap/ImapUtility.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2015 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.phone.common.mail.store.imap; + +/** + * Utility methods for use with IMAP. + */ +public class ImapUtility { + /** + * Apply quoting rules per IMAP RFC, + * quoted = DQUOTE *QUOTED-CHAR DQUOTE + * QUOTED-CHAR = <any TEXT-CHAR except quoted-specials> / "\" 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 + "\""; + } +}
\ No newline at end of file |