/* 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.imap2; import android.content.ContentProviderOperation; import android.content.ContentProviderOperation.Builder; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.OperationApplicationException; import android.database.Cursor; import android.net.TrafficStats; import android.os.Bundle; import android.os.RemoteException; import android.util.Log; import com.android.emailcommon.TrafficFlags; import com.android.emailcommon.internet.MimeUtility; import com.android.emailcommon.mail.Address; import com.android.emailcommon.mail.CertificateValidationException; import com.android.emailcommon.mail.MessagingException; import com.android.emailcommon.provider.Account; import com.android.emailcommon.provider.EmailContent.Attachment; import com.android.emailcommon.provider.EmailContent.Body; 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.EmailContent; import com.android.emailcommon.provider.HostAuth; import com.android.emailcommon.provider.Mailbox; import com.android.emailcommon.provider.MailboxUtilities; import com.android.emailcommon.provider.ProviderUnavailableException; import com.android.emailcommon.provider.EmailContent.Message; import com.android.emailcommon.service.EmailServiceProxy; import com.android.emailcommon.service.EmailServiceStatus; import com.android.emailcommon.service.SyncWindow; import com.android.emailcommon.utility.SSLUtils; import com.android.emailcommon.utility.TextUtilities; import com.android.emailcommon.utility.Utility; import com.android.emailsync.AbstractSyncService; import com.android.emailsync.PartRequest; import com.android.emailsync.Request; import com.android.emailsync.SyncManager; import com.android.mail.providers.UIProvider; import com.beetstra.jutf7.CharsetProvider; import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.Writer; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; import java.net.SocketException; import java.net.SocketTimeoutException; import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.Stack; import java.util.regex.Matcher; import java.util.regex.Pattern; 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; public class Imap2SyncService extends AbstractSyncService { private static final String IMAP_OK = "OK"; private static final SimpleDateFormat GMAIL_INTERNALDATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yy HH:mm:ss z"); private static final String IMAP_ERR = "ERR"; private static final SimpleDateFormat IMAP_DATE_FORMAT = new SimpleDateFormat("dd-MMM-yyyy"); private static final SimpleDateFormat INTERNALDATE_FORMAT = new SimpleDateFormat("dd-MMM-yy HH:mm:ss z"); private static final Charset MODIFIED_UTF_7_CHARSET = new CharsetProvider().charsetForName("X-RFC-3501"); public static final String IMAP_DELETED_MESSAGES_FOLDER_NAME = "AndroidMail Trash"; public static final String GMAIL_TRASH_FOLDER = "[Gmail]/Trash"; private static Pattern IMAP_RESPONSE_PATTERN = Pattern.compile("\\*(\\s(\\d+))?\\s(\\w+).*"); private static final int HEADER_BATCH_COUNT = 10; // private static final int IDLE_TIMEOUT_MILLIS = 12*MINS; private static final int SECONDS = 1000; private static final int MINS = 60*SECONDS; private static final int IDLE_ASLEEP_MILLIS = 11*MINS; // private static final int COMMAND_TIMEOUT_MILLIS = 24*SECS; private static final int SOCKET_CONNECT_TIMEOUT = 10*SECONDS; private static final int SOCKET_TIMEOUT = 20*SECONDS; private ContentResolver mResolver; private int mWriterTag = 1; private boolean mIsGmail = false; private boolean mIsIdle = false; private int mLastExists = -1; private ArrayList mImapResponse = null; private String mImapResult; private String mImapErrorLine = null; private Socket mSocket = null; private boolean mStop = false; public int mServiceResult = 0; private boolean mIsServiceRequestPending = false; private final String[] MAILBOX_SERVER_ID_ARGS = new String[2]; public Imap2SyncService() { this("Imap2 Validation"); } private final ArrayList SERVER_DELETES = new ArrayList(); private static final String INBOX_SERVER_NAME = "Inbox"; // Per RFC3501 private BufferedWriter mWriter; private ImapInputStream mReader; private HostAuth mHostAuth; private String mPrefix; public Imap2SyncService(Context _context, Mailbox _mailbox) { super(_context, _mailbox); mResolver = _context.getContentResolver(); MAILBOX_SERVER_ID_ARGS[0] = Long.toString(mMailboxId); } private Imap2SyncService(String prefix) { super(prefix); } public Imap2SyncService(Context _context, Account _account) { this("Imap2 Account"); mContext = _context; mResolver = _context.getContentResolver(); mAccount = _account; mHostAuth = HostAuth.restoreHostAuthWithId(_context, mAccount.mHostAuthKeyRecv); mPrefix = mHostAuth.mDomain; } @Override public boolean alarm() { // See if we've got anything to do... Cursor updates = getUpdatesCursor(); Cursor deletes = getDeletesCursor(); try { if (mRequestQueue.isEmpty() && updates == null && deletes == null) { userLog("Ping: nothing to do"); } else { int cnt = mRequestQueue.size(); if (updates != null) { cnt += updates.getCount(); } if (deletes != null) { cnt += deletes.getCount(); } userLog("Ping: " + cnt + " tasks"); ping(); } } finally { if (updates != null) { updates.close(); } if (deletes != null) { deletes.close(); } } return true; } @Override public void reset() { // TODO Auto-generated method stub } public void addRequest(Request req) { super.addRequest(req); if (req instanceof PartRequest) { userLog("Request for attachment " + ((PartRequest)req).mAttachment.mId); } ping(); } @Override public Bundle validateAccount(HostAuth hostAuth, Context context) { Bundle bundle = new Bundle(); int resultCode = MessagingException.IOERROR; Connection conn = connectAndLogin(hostAuth, "main"); if (conn.status == EXIT_DONE) { resultCode = MessagingException.NO_ERROR; } else if (conn.status == EXIT_LOGIN_FAILURE) { resultCode = MessagingException.AUTHENTICATION_FAILED; } bundle.putInt(EmailServiceProxy.VALIDATE_BUNDLE_RESULT_CODE, resultCode); return bundle; } public void loadFolderList() throws IOException { HostAuth hostAuth = HostAuth.restoreHostAuthWithId(mContext, mAccount.mHostAuthKeyRecv); if (hostAuth == null) return; Connection conn = connectAndLogin(hostAuth, "folderList"); if (conn.status == EXIT_DONE) { setConnection(conn); readFolderList(); conn.socket.close(); } } private void setConnection(Connection conn) { mConnection = conn; mWriter = conn.writer; mReader = conn.reader; mSocket = conn.socket; } @Override public void resetCalendarSyncKey() { // Not used by Imap2 } public void ping() { mIsServiceRequestPending = true; Imap2SyncManager.runAwake(mMailbox.mId); if (mSocket != null) { try { if (mIsIdle) { userLog("breakIdle; sending DONE..."); mWriter.write("DONE\r\n"); mWriter.flush(); } } catch (SocketException e) { } catch (IOException e) { } } } public void stop () { if (mSocket != null) try { if (mIsIdle) ping(); mSocket.close(); } catch (IOException e) { } mStop = true; } public String writeCommand (Writer out, String cmd) { try { Integer t = mWriterTag++; String tag = "@@a" + t + ' '; out.write(tag); out.write(cmd); out.write("\r\n"); out.flush(); if (!cmd.startsWith("login")) userLog(tag + cmd); return tag; } catch (IOException e) { userLog("IOException in writeCommand"); } return null; } private long readLong (String str, int idx) { char ch = str.charAt(idx); long num = 0; while (ch >= '0' && ch <= '9') { num = (num * 10) + (ch - '0'); ch = str.charAt(++idx); } return num; } private void readUntagged(String str) { // Skip the "* " Parser p = new Parser(str, 2); String type = p.parseAtom(); int val = -1; if (type != null) { char c = type.charAt(0); if (c >= '0' && c <= '9') try { val = Integer.parseInt(type); type = p.parseAtom(); if (p != null) { if (type.toLowerCase().equals("exists")) mLastExists = val; } } catch (NumberFormatException e) { } else if (mMailbox.mSyncKey == null || mMailbox.mSyncKey == "0") { str = str.toLowerCase(); int idx = str.indexOf("uidvalidity"); if (idx > 0) { //*** 12? long num = readLong(str, idx + 12); mMailbox.mSyncKey = Long.toString(num); ContentValues cv = new ContentValues(); cv.put(MailboxColumns.SYNC_KEY, mMailbox.mSyncKey); mContext.getContentResolver().update( ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailbox.mId), cv, null, null); } } } userLog("Untagged: " + type); } private boolean caseInsensitiveStartsWith(String str, String tag) { return str.toLowerCase().startsWith(tag.toLowerCase()); } private String readResponse (ImapInputStream r, String tag) throws IOException { return readResponse(r, tag, null); } private String readResponse (ImapInputStream r, String tag, String command) throws IOException { mImapResult = IMAP_ERR; String str = null; if (command != null) mImapResponse = new ArrayList(); while (true) { str = r.readLine(); userLog("< " + str); if (caseInsensitiveStartsWith(str, tag)) { // This is the response from the command named 'tag' Parser p = new Parser(str, tag.length() - 1); mImapResult = p.parseAtom(); break; } else if (str.charAt(0) == '*') { if (command != null) { Matcher m = IMAP_RESPONSE_PATTERN.matcher(str); if (m.matches() && m.group(3).equals(command)) { mImapResponse.add(str); } else readUntagged(str); } else readUntagged(str); } else if (!mImapResponse.isEmpty()) { // Continuation with string literal, perhaps? int off = mImapResponse.size() - 1; mImapResponse.set(off, mImapResponse.get(off) + "\r\n" + str); } } if (!mImapResult.equals(IMAP_OK)) { userLog("$$$ Error result = " + mImapResult); mImapErrorLine = str; } return mImapResult; } String parseRecipientList (String str) { if (str == null) return null; ArrayList
list = new ArrayList
(); String r; Parser p = new Parser(str); while ((r = p.parseList()) != null) { Parser rp = new Parser(r); String displayName = rp.parseString(); rp.parseString(); String emailAddress = rp.parseString() + "@" + rp.parseString(); list.add(new Address(emailAddress, displayName)); } return Address.pack(list.toArray(new Address[list.size()])); } String parseRecipients (Parser p, Message msg) { msg.mFrom = parseRecipientList(p.parseListOrNil()); @SuppressWarnings("unused") String senderList = parseRecipientList(p.parseListOrNil()); msg.mReplyTo = parseRecipientList(p.parseListOrNil()); msg.mTo = parseRecipientList(p.parseListOrNil()); msg.mCc = parseRecipientList(p.parseListOrNil()); msg.mBcc = parseRecipientList(p.parseListOrNil()); return Address.toFriendly(Address.unpack(msg.mFrom)); } private Message createMessage (String str) { Parser p = new Parser(str, str.indexOf('(') + 1); Date date = null; String subject = null; String sender = null; boolean read = false; int flag = 0; String flags = null; int uid = 0; boolean bodystructure = false; Message msg = new Message(); msg.mMailboxKey = mMailboxId; try { while (true) { String atm = p.parseAtom(); // We're done if we have all of these, regardless of order if (date != null && flags != null && bodystructure) break; // Not sure if this case is possible if (atm == null) break; if (atm.equalsIgnoreCase("UID")) { uid = p.parseInteger(); //userLog("UID=" + uid); } else if (atm.equalsIgnoreCase("ENVELOPE")) { String envelope = p.parseList(); Parser ep = new Parser(envelope); ep.skipWhite(); //date = parseDate(ep.parseString()); ep.parseString(); subject = ep.parseString(); sender = parseRecipients(ep, msg); } else if (atm.equalsIgnoreCase("FLAGS")) { flags = p.parseList().toLowerCase(); if (flags.indexOf("\\seen") >=0) read = true; if (flags.indexOf("\\flagged") >=0) flag = 1; } else if (atm.equalsIgnoreCase("BODYSTRUCTURE")) { msg.mSyncData = p.parseList(); bodystructure = true; //parseBodystructure(msg, new Parser(bs), "", 1, parts); } else if (atm.equalsIgnoreCase("INTERNALDATE")) { date = parseInternaldate(p.parseString()); } } } catch (Exception e) { // Parsing error here. We've got one known one from EON // in which BODYSTRUCTURE is ( "MIXED" (....) ) if (sender == null) sender = "Unknown sender"; if (subject == null) subject = "No subject"; e.printStackTrace(); } if (subject != null && subject.startsWith("=?")) subject = MimeUtility.decode(subject); msg.mSubject = subject; //msg.bodyId = 0; //msg.parts = parts.toString(); msg.mAccountKey = mAccount.mId; msg.mFlagLoaded = Message.FLAG_LOADED_UNLOADED; msg.mFlags = flag; if (read) msg.mFlagRead = true; msg.mTimeStamp = ((date != null) ? date : new Date()).getTime(); msg.mServerId = Long.toString(uid); return msg; } private Date parseInternaldate (String str) { if (str != null) { SimpleDateFormat f = INTERNALDATE_FORMAT; if (str.charAt(3) == ',') f = GMAIL_INTERNALDATE_FORMAT; try { return f.parse(str); } catch (ParseException e) { userLog("Unparseable date: " + str); } } return new Date(); } private long getIdForUid(int uid) { // TODO: Rename this MAILBOX_SERVER_ID_ARGS[1] = Integer.toString(uid); Cursor c = mResolver.query(Message.CONTENT_URI, Message.ID_COLUMN_PROJECTION, MessageColumns.MAILBOX_KEY + "=? AND " + SyncColumns.SERVER_ID + "=?", MAILBOX_SERVER_ID_ARGS, null); try { if (c != null && c.moveToFirst()) { return c.getLong(Message.ID_COLUMNS_ID_COLUMN); } } finally { if (c != null) { c.close(); } } return Message.NO_MESSAGE; } private void processDelete(int uid) { SERVER_DELETES.clear(); SERVER_DELETES.add(uid); processServerDeletes(SERVER_DELETES); } /** * Handle a single untagged line * TODO: Perhaps batch operations for multiple lines into a single transaction */ private boolean handleUntagged (String line) { line = line.toLowerCase(); Matcher m = IMAP_RESPONSE_PATTERN.matcher(line); boolean res = false; if (m.matches()) { // What kind of thing is this? String type = m.group(3); if (type.equals("fetch") || type.equals("expunge")) { // This is a flag change or an expunge. First, find the UID int uid = 0; // TODO Get rid of hack to avoid uid... int uidPos = line.indexOf("uid"); if (uidPos > 0) { Parser p = new Parser(line, uidPos + 3); uid = p.parseInteger(); } if (uid == 0) { // This will be very inefficient // Have to 1) break idle, 2) query the server for uid return false; } long id = getIdForUid(uid); if (id == Message.NO_MESSAGE) { // Nothing to do; log userLog("? No message found for uid " + uid); return true; } if (type.equals("fetch")) { if (line.indexOf("\\deleted") > 0) { processDelete(uid); } else { boolean read = line.indexOf("\\seen") > 0; boolean flagged = line.indexOf("\\flagged") > 0; // TODO: Reuse ContentValues values = new ContentValues(); values.put(MessageColumns.FLAG_READ, read); values.put(MessageColumns.FLAG_FAVORITE, flagged); mResolver.update(ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, id), values, null, null); } userLog("<<< FLAGS " + uid); } else { userLog("<<< EXPUNGE " + uid); processDelete(uid); } } else if (type.equals("exists")) { int num = Integer.parseInt(m.group(2)); if (mIsGmail && (num == mLastExists)) { userLog("Gmail: nothing new..."); return false; } else if (mIsGmail) mLastExists = num; res = true; userLog("<<< EXISTS tag; new SEARCH"); } } return res; } /** * Prepends the folder name with the given prefix and UTF-7 encodes it. */ private String encodeFolderName(String name) { // do NOT add the prefix to the special name "INBOX" if ("inbox".equalsIgnoreCase(name)) return name; // Prepend prefix if (mPrefix != null) { name = mPrefix + 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; } private static class ServerUpdate { final long id; final int serverId; ServerUpdate(long _id, int _serverId) { id = _id; serverId = _serverId; } } private ArrayList mUpdatedIds = new ArrayList(); private ArrayList mDeletedIds = new ArrayList(); private Stack mDeletes = new Stack(); private Stack mReadUpdates = new Stack(); private Stack mUnreadUpdates = new Stack(); private Stack mFlaggedUpdates = new Stack(); private Stack mUnflaggedUpdates = new Stack(); private Cursor getUpdatesCursor() { Cursor c = mResolver.query(Message.UPDATED_CONTENT_URI, UPDATE_DELETE_PROJECTION, MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); if (c == null || c.getCount() == 0) { c.close(); return null; } return c; } private static final String[] UPDATE_DELETE_PROJECTION = new String[] {MessageColumns.ID, SyncColumns.SERVER_ID, MessageColumns.MAILBOX_KEY, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE}; private static final int UPDATE_DELETE_ID_COLUMN = 0; private static final int UPDATE_DELETE_SERVER_ID_COLUMN = 1; private static final int UPDATE_DELETE_MAILBOX_KEY_COLUMN = 2; private static final int UPDATE_DELETE_READ_COLUMN = 3; private static final int UPDATE_DELETE_FAVORITE_COLUMN = 4; private Cursor getDeletesCursor() { Cursor c = mResolver.query(Message.DELETED_CONTENT_URI, UPDATE_DELETE_PROJECTION, MessageColumns.MAILBOX_KEY + '=' + mMailbox.mId, null, null); if (c == null || c.getCount() == 0) { c.close(); return null; } return c; } private void handleLocalDeletes() throws IOException { Cursor c = getDeletesCursor(); if (c == null) return; mDeletes.clear(); mDeletedIds.clear(); try { while (c.moveToNext()) { long id = c.getLong(UPDATE_DELETE_ID_COLUMN); mDeletes.add(new ServerUpdate(id, c.getInt(UPDATE_DELETE_SERVER_ID_COLUMN))); mDeletedIds.add(id); } sendUpdate(mDeletes, "+FLAGS (\\Deleted)"); String tag = writeCommand(mConnection.writer, "expunge"); readResponse(mConnection.reader, tag, "expunge"); // Delete the deletions now (we must go deeper!) ArrayList ops = new ArrayList(); for (long id: mDeletedIds) { ops.add(ContentProviderOperation.newDelete( ContentUris.withAppendedId( Message.DELETED_CONTENT_URI, id)).build()); } applyBatch(ops); } finally { c.close(); } } private void handleLocalUpdates() throws IOException { Cursor updatesCursor = getUpdatesCursor(); if (updatesCursor == null) return; mUpdatedIds.clear(); mReadUpdates.clear(); mUnreadUpdates.clear(); mFlaggedUpdates.clear(); mUnflaggedUpdates.clear(); try { while (updatesCursor.moveToNext()) { long id = updatesCursor.getLong(UPDATE_DELETE_ID_COLUMN); // Keep going if there's no serverId int serverId = updatesCursor.getInt(UPDATE_DELETE_SERVER_ID_COLUMN); if (serverId == 0) { continue; } // Say we've handled this update mUpdatedIds.add(id); // We have the id of the changed item. But first, we have to find out its current // state, since the updated table saves the opriginal state Cursor currentCursor = mResolver.query( ContentUris.withAppendedId(Message.CONTENT_URI, id), UPDATE_DELETE_PROJECTION, null, null, null); try { // If this item no longer exists (shouldn't be possible), just move along if (!currentCursor.moveToFirst()) { continue; } boolean flagChange = false; boolean readChange = false; long mailboxId = currentCursor.getLong(UPDATE_DELETE_MAILBOX_KEY_COLUMN); // If the message is now in the trash folder, it has been deleted by the user if (mailboxId != updatesCursor.getLong(UPDATE_DELETE_MAILBOX_KEY_COLUMN)) { // The message has been moved to another mailbox Mailbox newMailbox = Mailbox.restoreMailboxWithId(mContext, mailboxId); if (newMailbox == null) { continue; } copyMessage(serverId, newMailbox); } // We can only send flag changes to the server in 12.0 or later int flag = currentCursor.getInt(UPDATE_DELETE_FAVORITE_COLUMN); if (flag != updatesCursor.getInt(UPDATE_DELETE_FAVORITE_COLUMN)) { flagChange = true; } int read = currentCursor.getInt(UPDATE_DELETE_READ_COLUMN); if (read != updatesCursor.getInt(UPDATE_DELETE_READ_COLUMN)) { readChange = true; } if (!flagChange && !readChange) { // In this case, we've got nothing to send to the server continue; } ServerUpdate update = new ServerUpdate(id, serverId); if (readChange) { if (read == 1) { mReadUpdates.add(update); } else { mUnreadUpdates.add(update); } } if (flagChange) { if (flag == 1) { mFlaggedUpdates.add(update); } else { mUnflaggedUpdates.add(update); } } } finally { currentCursor.close(); } } } finally { updatesCursor.close(); } if (!mUpdatedIds.isEmpty()) { sendUpdate(mReadUpdates, "+FLAGS (\\Seen)"); sendUpdate(mUnreadUpdates, "-FLAGS (\\Seen)"); sendUpdate(mFlaggedUpdates, "+FLAGS (\\Flagged)"); sendUpdate(mUnflaggedUpdates, "-FLAGS (\\Flagged)"); // Delete the updates now ArrayList ops = new ArrayList(); for (Long id: mUpdatedIds) { ops.add(ContentProviderOperation.newDelete( ContentUris.withAppendedId(Message.UPDATED_CONTENT_URI, id)).build()); } applyBatch(ops); } } private void sendUpdate(Stack updates, String command) throws IOException { // First, generate the appropriate String while (!updates.isEmpty()) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < 20 && !updates.empty(); i++) { ServerUpdate update = updates.pop(); if (i != 0) { sb.append(','); } sb.append(update.serverId); } String tag = writeCommand(mConnection.writer, "uid store " + sb.toString() + " " + command); if (!readResponse(mConnection.reader, tag, "STORE").equals(IMAP_OK)) { errorLog("Server flag update failed?"); return; } } } private void copyMessage(int serverId, Mailbox mailbox) throws IOException { String tag = writeCommand(mConnection.writer, "uid copy " + serverId + " \"" + encodeFolderName(mailbox.mServerId) + "\""); if (readResponse(mConnection.reader, tag, "COPY").equals(IMAP_OK)) { tag = writeCommand(mConnection.writer, "uid store " + serverId + " +FLAGS(\\Deleted)"); if (readResponse(mConnection.reader, tag, "STORE").equals(IMAP_OK)) { tag = writeCommand(mConnection.writer, "expunge"); readResponse(mConnection.reader, tag, "expunge"); } } else { errorLog("Server copy failed?"); } } private void saveNewMessages (ArrayList msgList) { // Cursor dc = getLocalDeletedCursor(); // ArrayList dl = new ArrayList(); // boolean newDeletions = false; // try { // if (dc.moveToFirst()) { // do { // dl.add(dc.getInt(Email.UID_COLUMN)); // newDeletions = true; // } while (dc.moveToNext()); // } // } finally { // dc.close(); // } ArrayList ops = new ArrayList(); for (Message msg: msgList) { //if (newDeletions && dl.contains(msg.uid)) { // userLog("PHEW! Didn't save deleted message with uid: " + msg.uid); // continue; //} msg.addSaveOps(ops); } applyBatch(ops); } private String readTextPart (ImapInputStream in, String tag, Attachment att, boolean lastPart) throws IOException { String res = in.readLine(); int bstart = res.indexOf("body["); if (bstart < 0) bstart = res.indexOf("BODY["); if (bstart < 0) return ""; int bend = res.indexOf(']', bstart); if (bend < 0) return ""; //String charset = getCharset(thisLoc); boolean qp = att.mEncoding.equalsIgnoreCase("quoted-printable"); int br = res.indexOf('{'); if (br > 0) { Parser p = new Parser(res, br + 1); int length = p.parseInteger(); int len = length; byte[] buf = new byte[len]; int offs = 0; while (len > 0) { int rd = in.read(buf, offs, len); offs += rd; len -= rd; } if (qp) { length = QuotedPrintable.decode(buf, length); } if (lastPart) { String line = in.readLine(); if (!line.endsWith(")")) { userLog("Bad text part?"); throw new IOException(); } line = in.readLine(); if (!line.startsWith(tag)) { userLog("Bad text part?"); throw new IOException(); } } return new String(buf, 0, length, Charset.forName("UTF8")); } else { return ""; } } private Thread mBodyThread; private Connection mConnection; private void parseBodystructure (Message msg, Parser p, String level, int cnt, ArrayList viewables, ArrayList attachments) { if (p.peekChar() == '(') { // Multipart variant while (true) { String ps = p.parseList(); if (ps == null) break; parseBodystructure(msg, new Parser(ps), level + ((level.length() > 0) ? '.' : "") + cnt, 1, viewables, attachments); cnt++; } // Multipart type (MIXED/ALTERNATIVE/RELATED) String mp = p.parseString(); userLog("Multipart: " + mp); } else { boolean attachment = true; String fileName = ""; // Here's an actual part... // mime type String type = p.parseString().toLowerCase(); // mime subtype String sub = p.parseString().toLowerCase(); // parameter list or NIL String paramList = p.parseList(); if (paramList == null) p.parseAtom(); else { Parser pp = new Parser(paramList); String param; while ((param = pp.parseString()) != null) { String val = pp.parseString(); if (param.equalsIgnoreCase("name")) { fileName = val; } else if (param.equalsIgnoreCase("charset")) { // TODO: Do we need to handle this? } } } // contentId String contentId = p.parseString(); if (contentId != null) { // Must remove the angle-bracket pair contentId = contentId.substring(1, contentId.length() - 1); fileName = ""; } // contentName p.parseString(); // encoding String encoding = p.parseString().toLowerCase(); // length Integer length = p.parseInteger(); String lvl = level.length() > 0 ? level : String.valueOf(cnt); // body MD5 p.parseStringOrAtom(); // disposition paramList = p.parseList(); if (paramList != null) { //A parenthesized list, consisting of a disposition type //string, followed by a parenthesized list of disposition //attribute/value pairs as defined in [DISPOSITION]. Parser pp = new Parser(paramList); String param; while ((param = pp.parseString()) != null) { String val = pp.parseString(); if (param.equalsIgnoreCase("name") || param.equalsIgnoreCase("filename")) { fileName = val; } } } // Don't waste time with Microsoft foolishness if (!sub.equals("ms-tnef")) { Attachment att = new Attachment(); att.mLocation = lvl; att.mMimeType = type + "/" + sub; att.mSize = length; att.mFileName = fileName; att.mEncoding = encoding; att.mContentId = contentId; // TODO: charset? if ((!type.startsWith("text")) && attachment) { //msg.encoding |= Email.ENCODING_HAS_ATTACHMENTS; attachments.add(att); } else { viewables.add(att); } userLog("Part " + lvl + ": " + type + "/" + sub); } } } private void fetchMessageData(Connection conn, Cursor c) throws IOException { for (;;) { try { if (c == null) { c = mResolver.query(Message.CONTENT_URI, Message.CONTENT_PROJECTION, MessageColumns.FLAG_LOADED + "=" + Message.FLAG_LOADED_UNLOADED, null, MessageColumns.TIMESTAMP + " desc"); if (c == null || c.getCount() == 0) { return; } } while (c.moveToNext()) { // Parse the message's bodystructure Message msg = new Message(); msg.restore(c); ArrayList viewables = new ArrayList(); ArrayList attachments = new ArrayList(); parseBodystructure(msg, new Parser(msg.mSyncData), "", 1, viewables, attachments); ContentValues values = new ContentValues(); values.put(MessageColumns.FLAG_LOADED, Message.FLAG_LOADED_COMPLETE); // Save the attachments... for (Attachment att: attachments) { att.mAccountKey = mAccount.mId; att.mMessageKey = msg.mId; att.save(mContext); } // Whether or not we have attachments values.put(MessageColumns.FLAG_ATTACHMENT, !attachments.isEmpty()); // Get the viewables Attachment textViewable = null; for (Attachment viewable: viewables) { String mimeType = viewable.mMimeType; if ("text/html".equalsIgnoreCase(mimeType)) { textViewable = viewable; } else if ("text/plain".equalsIgnoreCase(mimeType) && textViewable == null) { textViewable = viewable; } } if (textViewable != null) { // For now, just get single viewable String tag = writeCommand(conn.writer, "uid fetch " + msg.mServerId + " body.peek[" + textViewable.mLocation + "]<0.200000>"); String text = readTextPart(conn.reader, tag, textViewable, true); userLog("Viewable " + textViewable.mMimeType + ", len = " + text.length()); // Save it away Body body = new Body(); if (textViewable.mMimeType.equalsIgnoreCase("text/html")) { body.mHtmlContent = text; } else { body.mTextContent = text; } body.mMessageKey = msg.mId; body.save(mContext); values.put(MessageColumns.SNIPPET, TextUtilities.makeSnippetFromHtmlText(text)); } else { userLog("No viewable?"); values.putNull(MessageColumns.SNIPPET); } mResolver.update(ContentUris.withAppendedId( Message.CONTENT_URI, msg.mId), values, null, null); } } finally { if (c != null) { c.close(); c = null; } } } } private void fetchMessageData () throws IOException { // If we're already loading messages on another thread, there's nothing to do if (mBodyThread != null) return; HostAuth hostAuth = HostAuth.restoreHostAuthWithId(mContext, mAccount.mHostAuthKeyRecv); if (hostAuth == null) return; // Find messages to load, if any final Cursor unloaded = mResolver.query(Message.CONTENT_URI, Message.CONTENT_PROJECTION, MessageColumns.FLAG_LOADED + "=" + Message.FLAG_LOADED_UNLOADED, null, MessageColumns.TIMESTAMP + " desc"); int cnt = unloaded.getCount(); // If there aren't any, we're done if (cnt > 0) { userLog("Found " + unloaded.getCount() + " messages requiring fetch"); // If we have more than one, try a second thread // Some servers may not allow this, so we fall back to loading text on the main thread if (cnt > 1) { final Connection conn = connectAndLogin(hostAuth, "body"); if (conn.status == EXIT_DONE) { mBodyThread = new Thread(new Runnable() { @Override public void run() { try { fetchMessageData(conn, unloaded); conn.socket.close(); } catch (IOException e) { } finally { mBodyThread = null; } }}); mBodyThread.start(); } else { fetchMessageData(mConnection, unloaded); } } else { fetchMessageData(mConnection, unloaded); } } } void readFolderList () throws IOException { String tag = writeCommand(mWriter, "list \"\" *"); String line; char dchar = '/'; userLog("Loading folder list..."); ArrayList parentList = new ArrayList(); ArrayList mailboxList = new ArrayList(); while (true) { line = mReader.readLine(); userLog(line); if (line.startsWith(tag)) { // Done reading folder list break; } else { Parser p = new Parser(line, 2); String cmd = p.parseAtom(); if (cmd.equalsIgnoreCase("list")) { @SuppressWarnings("unused") String props = p.parseListOrNil(); String delim = p.parseString(); if (delim == null) delim = "~"; if (delim.length() == 1) dchar = delim.charAt(0); String serverId = p.parseStringOrAtom(); int lastDelim = serverId.lastIndexOf(delim); String displayName; String parentName; if (lastDelim > 0) { displayName = serverId.substring(lastDelim + 1); parentName = serverId.substring(0, lastDelim); } else { displayName = serverId; parentName = null; } Mailbox m = new Mailbox(); m.mDisplayName = displayName; m.mAccountKey = mAccount.mId; m.mServerId = serverId; if (parentName != null && !parentList.contains(parentName)) { parentList.add(parentName); } m.mFlagVisible = true; m.mParentServerId = parentName; m.mDelimiter = dchar; m.mSyncInterval = Mailbox.CHECK_INTERVAL_NEVER; mailboxList.add(m); } else { // WTF } } } // TODO: Use narrower projection Cursor c = mResolver.query(Mailbox.CONTENT_URI, Mailbox.CONTENT_PROJECTION, Mailbox.ACCOUNT_KEY + "=?", new String[] {Long.toString(mAccount.mId)}, MailboxColumns.SERVER_ID + " asc"); if (c == null) return; int cnt = c.getCount(); String[] serverIds = new String[cnt]; long[] uidvals = new long[cnt]; long[] ids = new long[cnt]; int i = 0; try { if (c.moveToFirst()) { // Get arrays of information about existing mailboxes in account do { serverIds[i] = c.getString(Mailbox.CONTENT_SERVER_ID_COLUMN); uidvals[i] = c.getLong(Mailbox.CONTENT_SYNC_KEY_COLUMN); ids[i] = c.getLong(Mailbox.CONTENT_ID_COLUMN); i++; } while (c.moveToNext()); } } finally { c.close(); } ArrayList addList = new ArrayList(); for (Mailbox m: mailboxList) { int loc = Arrays.binarySearch(serverIds, m.mServerId); if (loc >= 0) { // It exists if (uidvals[loc] == 0) { // Good enough; a match that we've never visited! // Mark this as touched (-1)... uidvals[loc] = -1; } else { // Ok, now we need to see if this is the SAME mailbox... // For now, assume it is; move on // TODO: There's a problem if you've 1) visited this box and 2) another box now // has its name, but how likely is that?? uidvals[loc] = -1; } } else { // We don't know about this mailbox, so we'll add it... // BUT must see if it's a rename of one we've visited! addList.add(m); } } // TODO: Flush this list every N (100?) in case there are zillions ArrayList ops = new ArrayList(); try { for (i = 0; i < cnt; i++) { String name = serverIds[i]; long uidval = uidvals[i]; // -1 means matched; ignore // 0 means unmatched and never before seen // > 0 means unmatched and HAS been seen. must find mWriter why // TODO: Get rid of "Outbox" if (uidval == 0 && !name.equals("Outbox") && !name.equalsIgnoreCase(INBOX_SERVER_NAME)) { // Ok, here's one we've never visited and it's not in the new list ops.add(ContentProviderOperation.newDelete(ContentUris.withAppendedId( Mailbox.CONTENT_URI, ids[i])).build()); userLog("Deleting unseen mailbox; no match: " + name); } else if (uidval > 0 && !name.equalsIgnoreCase(INBOX_SERVER_NAME)) { boolean found = false; for (Mailbox m : addList) { tag = writeCommand(mWriter, "status \"" + m.mServerId + "\" (UIDVALIDITY)"); if (readResponse(mReader, tag, "STATUS").equals(IMAP_OK)) { String str = mImapResponse.get(0).toLowerCase(); int idx = str.indexOf("uidvalidity"); long num = readLong(str, idx + 12); if (uidval == num) { // try { // // This is a renamed mailbox... // c = Mailbox.getCursorWhere(mDatabase, "account=" + mAccount.id + " and serverName=?", name); // if (c != null && c.moveToFirst()) { // Mailbox existing = Mailbox.restoreFromCursor(c); // userLog("Renaming existing mailbox: " + existing.mServerId + " to: " + m.mServerId); // existing.mDisplayName = m.mDisplayName; // existing.mServerId = m.mServerId; // m.mHierarchicalName = m.mServerId; // existing.mParentServerId = m.mParentServerId; // existing.mFlags = m.mFlags; // existing.save(mDatabase); // // Mark this so that we don't save it below // m.mServerId = null; // } // } finally { // if (c != null) { // c.close(); // } // } found = true; break; } } } if (!found) { // There's no current mailbox with this uidval, so delete. ops.add(ContentProviderOperation.newDelete(ContentUris.withAppendedId( Mailbox.CONTENT_URI, ids[i])).build()); userLog("Deleting uidval mailbox; no match: " + name); } } } for (Mailbox m : addList) { String serverId = m.mServerId; if (serverId == null) continue; if (!serverId.equalsIgnoreCase(INBOX_SERVER_NAME) && !serverId.equalsIgnoreCase("Outbox")) { m.mHierarchicalName = m.mServerId; //*** For now, use Mail. We need a way to select the others... m.mType = Mailbox.TYPE_MAIL; ops.add(ContentProviderOperation.newInsert( Mailbox.CONTENT_URI).withValues(m.toContentValues()).build()); userLog("Adding new mailbox: " + m.mServerId); } } applyBatch(ops); // Fixup parent stuff, flags... MailboxUtilities.fixupUninitializedParentKeys(mContext, Mailbox.ACCOUNT_KEY + "=" + mAccount.mId); } finally { SyncManager.kick("folder list"); } // TODO: Make sure UI is updated } public int getDepth (String name, char delim) { int depth = 0; int last = -1; while (true) { last = name.indexOf(delim, last + 1); if (last < 0) return depth; depth++; } } private void applyBatch(ArrayList ops) { try { mResolver.applyBatch(EmailContent.AUTHORITY, ops); } catch (RemoteException e) { // Nothing to be done } catch (OperationApplicationException e) { // These operations are legal; this can't really happen } } private void processServerDeletes(ArrayList deleteList) { int cnt = deleteList.size(); if (cnt > 0) { ArrayList ops = new ArrayList(); for (int i = 0; i < cnt; i++) { MAILBOX_SERVER_ID_ARGS[1] = Long.toString(deleteList.get(i)); Builder b = ContentProviderOperation.newDelete( Message.SYNCED_SELECTION_CONTENT_URI); b.withSelection(MessageColumns.MAILBOX_KEY + "=? AND " + SyncColumns.SERVER_ID + "=?", MAILBOX_SERVER_ID_ARGS); ops.add(b.build()); } applyBatch(ops); } } private void processServerUpdates(ArrayList deleteList, ContentValues values) { int cnt = deleteList.size(); if (cnt > 0) { ArrayList ops = new ArrayList(); for (int i = 0; i < cnt; i++) { MAILBOX_SERVER_ID_ARGS[1] = Long.toString(deleteList.get(i)); Builder b = ContentProviderOperation.newUpdate( Message.SYNCED_SELECTION_CONTENT_URI); b.withSelection(MessageColumns.MAILBOX_KEY + "=? AND " + SyncColumns.SERVER_ID + "=?", MAILBOX_SERVER_ID_ARGS); b.withValues(values); ops.add(b.build()); } applyBatch(ops); } } private static class Reconciled { ArrayList insert; ArrayList delete; Reconciled (ArrayList ins, ArrayList del) { insert = ins; delete = del; } } // Arrays must be sorted beforehand public Reconciled reconcile (String name, int[] deviceList, int[] serverList) { ArrayList loadList = new ArrayList(); ArrayList deleteList = new ArrayList(); int soff = 0; int doff = 0; int scnt = serverList.length; int dcnt = deviceList.length; while (scnt > 0 || dcnt > 0) { if (scnt == 0) { for (; dcnt > 0; dcnt--) deleteList.add(deviceList[doff++]); break; } else if (dcnt == 0) { for (; scnt > 0; scnt--) loadList.add(serverList[soff++]); break; } int s = serverList[soff++]; int d = deviceList[doff++]; scnt--; dcnt--; if (s == d) { continue; } else if (s > d) { deleteList.add(d); scnt++; soff--; } else if (d > s) { loadList.add(s); dcnt++; doff--; } } userLog("Reconciler " + name + "-> Insert: " + loadList.size() + ", Delete: " + deleteList.size()); return new Reconciled(loadList, deleteList); } private static final String[] UID_PROJECTION = new String[] {SyncColumns.SERVER_ID}; public int[] getUidList (String andClause) { int offs = 0; String ac = MessageColumns.MAILBOX_KEY + "=?"; if (andClause != null) { ac = ac + andClause; } Cursor c = mResolver.query(Message.CONTENT_URI, UID_PROJECTION, ac, new String[] {Long.toString(mMailboxId)}, SyncColumns.SERVER_ID); if (c != null) { try { int[] uids = new int[c.getCount()]; if (c.moveToFirst()) { do { uids[offs++] = c.getInt(0); } while (c.moveToNext()); return uids; } } finally { c.close(); } } return new int[0]; } public int[] getUnreadUidList () { return getUidList(" and " + Message.FLAG_READ + "=0"); } public int[] getFlaggedUidList () { return getUidList(" and " + Message.FLAG_FAVORITE + "!=0"); } private void reconcileState(int[] deviceList, String since, String flag, String search, String column, boolean sense) throws IOException { int[] serverList; Parser p; String msgs; String tag = writeCommand(mWriter, "uid search undeleted " + search + " since " + since); if (readResponse(mReader, tag, "SEARCH").equals(IMAP_OK)) { if (mImapResponse.isEmpty()) { serverList = new int[0]; } else { msgs = mImapResponse.get(0); p = new Parser(msgs, 8); serverList = p.gatherInts(); Arrays.sort(serverList); } Reconciled r = reconcile(flag, deviceList, serverList); ContentValues values = new ContentValues(); values.put(column, sense); processServerUpdates(r.delete, values); values.put(column, !sense); processServerUpdates(r.insert, values); } } private ArrayList getTokens(String str) { ArrayList tokens = new ArrayList(); Parser p = new Parser(str); while(true) { String capa = p.parseAtom(); if (capa == null) { break; } tokens.add(capa); } return tokens; } public static class Connection { Socket socket; int status; ImapInputStream reader; BufferedWriter writer; } private String mUserAgent; private Connection connectAndLogin(HostAuth hostAuth, String name) { Connection conn = new Connection(); Socket socket; try { socket = getSocket(hostAuth); socket.setSoTimeout(SOCKET_TIMEOUT); userLog(">>> IMAP CONNECTION SUCCESSFUL: " + name); ImapInputStream reader = new ImapInputStream(socket.getInputStream()); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( socket.getOutputStream())); // Get welcome string reader.readLine(); String tag = writeCommand(writer, "CAPABILITY"); if (readResponse(reader, tag, "CAPABILITY").equals(IMAP_OK)) { // If CAPABILITY if (!mImapResponse.isEmpty()) { String capa = mImapResponse.get(0).toLowerCase(); ArrayList tokens = getTokens(capa); if (tokens.contains("starttls")) { // Handle STARTTLS userLog("[Supports STARTTLS]"); } if (tokens.contains("id")) { String hostAddress = hostAuth.mAddress; // Never send ID to *.secureserver.net // Hackish, yes, but we've been doing this for years... :-( if (!hostAddress.toLowerCase().endsWith(".secureserver.net")) { // Assign user-agent string (for RFC2971 ID command) if (mUserAgent == null) { mUserAgent = ImapId.getImapId(mContext, hostAuth.mLogin, hostAddress, null); } tag = writeCommand(writer, "ID (" + mUserAgent + ")"); // We learn nothing useful from the response readResponse(reader, tag); } } } } tag = writeCommand(writer, "login " + hostAuth.mLogin + ' ' + hostAuth.mPassword); if (!readResponse(reader, tag).equals(IMAP_OK)) { conn.status = EXIT_LOGIN_FAILURE; } else { conn.socket = socket; conn.reader = reader; conn.writer = writer; conn.status = EXIT_DONE; userLog(">>> LOGGED IN: " + name); if (mMailboxName != null) { tag = writeCommand(conn.writer, "select \"" + encodeFolderName(mMailboxName) + '\"'); if (!readResponse(conn.reader, tag).equals(IMAP_OK)) { // Select failed userLog("Select failed?"); conn.status = EXIT_EXCEPTION; } else { userLog(">>> SELECTED"); } } } } catch (CertificateValidationException e) { conn.status = EXIT_LOGIN_FAILURE; } catch (IOException e) { conn.status = EXIT_IO_ERROR; } return conn; } private void setMailboxSyncStatus(long id, int status) { ContentValues values = new ContentValues(); values.put(Mailbox.UI_SYNC_STATUS, status); // Make sure we're always showing a "success" value. A failure wouldn't get set here, but // rather via SyncService.done() values.put(Mailbox.UI_LAST_SYNC_RESULT, Mailbox.LAST_SYNC_RESULT_SUCCESS); mResolver.update(ContentUris.withAppendedId(Mailbox.CONTENT_URI, id), values, null, null); } private void idle() throws IOException { mIsIdle = true; mThread.setName(mMailboxName + ":IDLE[" + mAccount.mDisplayName + "]"); userLog("Entering idle..."); String tag = writeCommand(mWriter, "idle"); try { while (true) { String resp = mReader.readLine(); if (resp.startsWith("+")) break; // Remember to handle untagged responses here (sigh, and elsewhere) if (resp.startsWith("* ")) handleUntagged(resp); else { userLog("Error in IDLE response: " + resp); //*** How to handle this? return; } } // Server has accepted IDLE long idleStartTime = System.currentTimeMillis(); // Let the socket time out a minute after we expect to terminate it ourselves mSocket.setSoTimeout(IDLE_ASLEEP_MILLIS + (1*MINS)); // Say we're no longer syncing (turn off indeterminate progress in the UI) setMailboxSyncStatus(mMailboxId, UIProvider.SyncStatus.NO_SYNC); // Set an alarm for one minute before our timeout our expected IDLE time Imap2SyncManager.runAsleep(mMailboxId, IDLE_ASLEEP_MILLIS); while (true) { String line = null; try { line = mReader.readLine(); userLog(line); } catch (SocketTimeoutException e) { userLog("Socket timeout"); } finally { Imap2SyncManager.runAwake(mMailboxId); // Say we're syncing again setMailboxSyncStatus(mMailboxId, UIProvider.SyncStatus.BACKGROUND_SYNC); } if (line == null || line.startsWith("* ")) { boolean finish = (line == null) ? true : handleUntagged(line); if (!finish) { long timeSinceIdle = System.currentTimeMillis() - idleStartTime; // If we're nearing the end of IDLE time, let's just reset the IDLE while // we've got the processor awake if (timeSinceIdle > IDLE_ASLEEP_MILLIS - (2*MINS)) { userLog("Time to reset IDLE..."); finish = true; } } if (finish) { mWriter.write("DONE\r\n"); mWriter.flush(); } } else if (line.startsWith(tag)) { Parser p = new Parser(line, tag.length() - 1); mImapResult = p.parseAtom(); mIsIdle = false; break; } } } finally { // We might have left IDLE due to an exception if (mSocket != null) { // Reset the standard timeout mSocket.setSoTimeout(20 * 1000); } mIsIdle = false; mThread.setName(mMailboxName + "[" + mAccount.mDisplayName + "]"); } } // Upload sent messages to server // void foo() { // Cursor c = ServerUploads.getCursorWhere(mDatabase, "account=" + mAccount.id); // ArrayList uploaded = new ArrayList(); // try { // if (c.moveToFirst()) { // do{ // Mailbox m = Mailbox.restoreFromId(mDatabase, c.getLong(ServerUploads.TO_MAILBOX_COLUMN)); // String fn = c.getString(ServerUploads.FILENAME_COLUMN); // if (m != null) { // FileInputStream fi = null; // try { // fi = mContext.openFileInput(fn); // } catch (Exception e) { // logException(e); // } // if (fi != null) { // BufferedInputStream bin = new BufferedInputStream(fi); // mWriter.flush(); // BufferedOutputStream bos = new BufferedOutputStream(mSocket.getOutputStream()); // int len = fi.available(); // byte[] buf; // try { // tag = writeCommand(mWriter, "append \"" + m.serverName + "\" (\\seen) {" + len + '}'); // String line = in.readLine(); // buf = new byte[CHUNK_SIZE]; // if (line.startsWith("+")) { // userLog("append response: " + line); // // while (len > 0) { // int rlen = (len > CHUNK_SIZE) ? CHUNK_SIZE : len; // int rd = bin.read(buf, 0, rlen); // if (rd > 0) { // bos.write(buf, 0, rd); // } else if (rd < 0) // break; // len -= rd; // } // // bos.flush(); // mWriter.write("\r\n"); // mWriter.flush(); // bin.close(); // if (readResponse(in, tag).equals(IMAP_OK)) { // uploaded.add(c.getLong(ServerUploads.ID_COLUMN)); // File f = mContext.getFileStreamPath(fn); // if (f.delete()) { // userLog("Upload file deleted: " + fn); // } // } else { // userLog("Append failed?"); // uploaded.add(c.getLong(ServerUploads.ID_COLUMN)); // } // } else { // userLog("Append failed: " + line); // uploaded.add(c.getLong(ServerUploads.ID_COLUMN)); // } // } catch (Exception e) { // logException(e); // uploaded.add(c.getLong(ServerUploads.ID_COLUMN)); // } // // buf = null; // if (m.name.equals(Mailbox.DRAFTS_NAME)) // MailService.serviceRequest(m.id, 3000L); // } else { // userLog("Can't find file to upload, deleting upload record: " + fn); // uploaded.add(c.getLong(ServerUploads.ID_COLUMN)); // } // } // } while (c.moveToNext()); // } // } finally { // // Delete the upload records for those completed // for (Long id: uploaded) { // ServerUploads.deleteById(mDatabase, id); // } // c.close(); // } // } private void processRequests() throws IOException { while (!mRequestQueue.isEmpty()) { Request req = mRequestQueue.peek(); // Our two request types are PartRequest (loading attachment) and // MeetingResponseRequest (respond to a meeting request) if (req instanceof PartRequest) { TrafficStats.setThreadStatsTag( TrafficFlags.getAttachmentFlags(mContext, mAccount)); new AttachmentLoader(this, (PartRequest)req).loadAttachment(mConnection); TrafficStats.setThreadStatsTag( TrafficFlags.getSyncFlags(mContext, mAccount)); } // If there's an exception handling the request, we'll throw it // Otherwise, we remove the request mRequestQueue.remove(); } } private void sync () throws IOException { mThread = Thread.currentThread(); HostAuth hostAuth = HostAuth.restoreHostAuthWithId(mContext, mAccount.mHostAuthKeyRecv); if (hostAuth == null) return; Connection conn = connectAndLogin(hostAuth, "main"); if (conn.status != EXIT_DONE) { mExitStatus = conn.status; return; } setConnection(conn); // The account might have changed!! //*** Determine how to often to do this if (mMailboxName.equalsIgnoreCase("inbox")) { long startTime = System.currentTimeMillis(); readFolderList(); userLog("Folder list processed in " + (System.currentTimeMillis() - startTime) + "ms"); } while (!mStop) { try { while (!mStop) { mIsServiceRequestPending = false; // Now, handle various requests processRequests(); long days; if (mMailbox.mSyncLookback == SyncWindow.SYNC_WINDOW_UNKNOWN) { days = 14; } else days = SyncWindow.toDays(mMailbox.mSyncLookback); long time = System.currentTimeMillis() - (days*DAYS); Date date = new Date(time); String since = IMAP_DATE_FORMAT.format(date); String tag = writeCommand(mWriter, "uid search undeleted since " + since); // TODO Handle multi-line search result (google) if (!readResponse(mReader, tag, "SEARCH").equals(IMAP_OK)) { userLog("$$$ WHOA! Search failed? "); } userLog(">>> SEARCH RESULT"); int[] serverList; String msgs; Parser p; if (mImapResponse.isEmpty()) { serverList = new int[0]; } else { msgs = mImapResponse.get(0); //*** Magic number? p = new Parser(msgs, 8); serverList = p.gatherInts(); } Arrays.sort(serverList); int[] deviceList = getUidList(null); Reconciled r = reconcile("MESSAGES", deviceList, serverList); ArrayList loadList = r.insert; ArrayList deleteList = r.delete; serverList = null; deviceList = null; int cnt = loadList.size(); // We load message headers 20 at a time at this point... int idx= 1; boolean loadedSome = false; while (idx <= cnt) { ArrayList tmsgList = new ArrayList (); int tcnt = 0; StringBuilder tsb = new StringBuilder("uid fetch "); for (tcnt = 0; tcnt < HEADER_BATCH_COUNT && idx <= cnt; tcnt++, idx++) { // Load most recent first if (tcnt > 0) tsb.append(','); tsb.append(loadList.get(cnt - idx)); } tsb.append(" (uid internaldate flags envelope bodystructure)"); tag = writeCommand(mWriter, tsb.toString()); if (readResponse(mReader, tag, "FETCH").equals(IMAP_OK)) { // Create message and store for (int j = 0; j < tcnt; j++) { Message msg = createMessage(mImapResponse.get(j)); tmsgList.add(msg); } saveNewMessages(tmsgList); } fetchMessageData(); loadedSome = true; } // TODO: Use loader to watch for changes on unloaded body cursor if (!loadedSome) { fetchMessageData(); } // Reflect server deletions on device; do them all at once processServerDeletes(deleteList); handleLocalUpdates(); handleLocalDeletes(); reconcileState(getUnreadUidList(), since, "UNREAD", "unseen", MessageColumns.FLAG_READ, true); reconcileState(getFlaggedUidList(), since, "FLAGGED", "flagged", MessageColumns.FLAG_FAVORITE, false); // We're done if not pushing... if (mMailbox.mSyncInterval != Mailbox.CHECK_INTERVAL_PUSH) { mExitStatus = EXIT_DONE; return; } // If new requests have come in, process them if (mIsServiceRequestPending) continue; idle(); } } finally { if (mSocket != null) { try { mSocket.close(); } catch (IOException e) { } } } } } @Override public void run() { try { // If we've been stopped, we're done if (mStop) return; // Whether or not we're the account mailbox try { if ((mMailbox == null) || (mAccount == null)) { return; } else { int trafficFlags = TrafficFlags.getSyncFlags(mContext, mAccount); TrafficStats.setThreadStatsTag(trafficFlags | TrafficFlags.DATA_EMAIL); // We loop because someone might have put a request in while we were syncing // and we've missed that opportunity... do { if (mRequestTime != 0) { userLog("Looping for user request..."); mRequestTime = 0; } if (mSyncReason >= Imap2SyncManager.SYNC_CALLBACK_START) { try { Imap2SyncManager.callback().syncMailboxStatus(mMailboxId, EmailServiceStatus.IN_PROGRESS, 0); } catch (RemoteException e1) { // Don't care if this fails } } sync(); } while (mRequestTime != 0); } } catch (IOException e) { String message = e.getMessage(); userLog("Caught IOException: ", (message == null) ? "No message" : message); mExitStatus = EXIT_IO_ERROR; } catch (Exception e) { userLog("Uncaught exception in EasSyncService", e); } finally { int status; Imap2SyncManager.done(this); if (!mStop) { userLog("Sync finished"); switch (mExitStatus) { case EXIT_IO_ERROR: status = EmailServiceStatus.CONNECTION_ERROR; break; case EXIT_DONE: status = EmailServiceStatus.SUCCESS; ContentValues cv = new ContentValues(); cv.put(Mailbox.SYNC_TIME, System.currentTimeMillis()); String s = "S" + mSyncReason + ':' + status + ':' + mChangeCount; cv.put(Mailbox.SYNC_STATUS, s); mContext.getContentResolver().update( ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId), cv, null, null); break; case EXIT_LOGIN_FAILURE: status = EmailServiceStatus.LOGIN_FAILED; break; default: status = EmailServiceStatus.REMOTE_EXCEPTION; errorLog("Sync ended due to an exception."); break; } } else { userLog("Stopped sync finished."); status = EmailServiceStatus.SUCCESS; } // Send a callback (doesn't matter how the sync was started) try { // Unless the user specifically asked for a sync, we don't want to report // connection issues, as they are likely to be transient. In this case, we // simply report success, so that the progress indicator terminates without // putting up an error banner //*** if (mSyncReason != Imap2SyncManager.SYNC_UI_REQUEST && status == EmailServiceStatus.CONNECTION_ERROR) { status = EmailServiceStatus.SUCCESS; } Imap2SyncManager.callback().syncMailboxStatus(mMailboxId, status, 0); } catch (RemoteException e1) { // Don't care if this fails } // Make sure ExchangeService knows about this Imap2SyncManager.kick("sync finished"); } } catch (ProviderUnavailableException e) { Log.e(TAG, "EmailProvider unavailable; sync ended prematurely"); } } private Socket getSocket(HostAuth hostAuth) throws CertificateValidationException, IOException { Socket socket; try { boolean ssl = (hostAuth.mFlags & HostAuth.FLAG_SSL) != 0; boolean trust = (hostAuth.mFlags & HostAuth.FLAG_TRUST_ALL) != 0; SocketAddress socketAddress = new InetSocketAddress(hostAuth.mAddress, hostAuth.mPort); if (ssl) { socket = SSLUtils.getSSLSocketFactory(trust).createSocket(); } else { socket = new Socket(); } socket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT); // After the socket connects to an SSL server, confirm that the hostname is as expected if (ssl && !trust) { verifyHostname(socket, hostAuth.mAddress); } } catch (SSLException e) { errorLog(e.toString()); throw new CertificateValidationException(e.getMessage(), e); } return socket; } /** * 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. You MUST call this if you did not supply a hostname * to SSLCertificateSocketFactory.createSocket(). It is harmless to call this method * redundantly if the hostname has already been verified. * *

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 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 (!HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session)) { throw new SSLPeerUnverifiedException( "Certificate hostname not useable for server: " + hostname); } } }