diff options
Diffstat (limited to 'src/com')
| -rw-r--r-- | src/com/android/email/imap2/AttachmentLoader.java | 203 | ||||
| -rw-r--r-- | src/com/android/email/imap2/EmailSyncAdapterService.java | 122 | ||||
| -rw-r--r-- | src/com/android/email/imap2/Imap2SyncManager.java | 359 | ||||
| -rw-r--r-- | src/com/android/email/imap2/Imap2SyncService.java | 2486 | ||||
| -rw-r--r-- | src/com/android/email/imap2/ImapId.java | 196 | ||||
| -rw-r--r-- | src/com/android/email/imap2/ImapInputStream.java | 48 | ||||
| -rw-r--r-- | src/com/android/email/imap2/Parser.java | 216 | ||||
| -rw-r--r-- | src/com/android/email/imap2/QuotedPrintable.java | 121 | ||||
| -rw-r--r-- | src/com/android/email/imap2/SearchRequest.java | 43 | ||||
| -rw-r--r-- | src/com/android/email/imap2/smtp/MailTransport.java | 325 | ||||
| -rw-r--r-- | src/com/android/email/imap2/smtp/SmtpSender.java | 306 | ||||
| -rw-r--r-- | src/com/android/email/service/EmailServiceUtils.java | 133 | ||||
| -rw-r--r-- | src/com/android/email/service/MailService.java | 11 |
13 files changed, 4563 insertions, 6 deletions
diff --git a/src/com/android/email/imap2/AttachmentLoader.java b/src/com/android/email/imap2/AttachmentLoader.java new file mode 100644 index 000000000..2e776be29 --- /dev/null +++ b/src/com/android/email/imap2/AttachmentLoader.java @@ -0,0 +1,203 @@ +/* Copyright (C) 2012 The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2; + +import android.content.Context; +import android.os.RemoteException; + +import com.android.email.imap2.Imap2SyncService.Connection; +import com.android.emailcommon.provider.EmailContent.Attachment; +import com.android.emailcommon.provider.EmailContent.Message; +import com.android.emailcommon.service.EmailServiceStatus; +import com.android.emailsync.PartRequest; +import com.android.emailcommon.utility.AttachmentUtilities; +import com.android.mail.providers.UIProvider; + +import org.apache.james.mime4j.decoder.Base64InputStream; + +import java.io.Closeable; +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; + +/** + * Handle IMAP2 attachment loading + */ +public class AttachmentLoader { + static private final int CHUNK_SIZE = 16*1024; + + private final Context mContext; + private final Attachment mAttachment; + private final long mAttachmentId; + private final long mMessageId; + private final Message mMessage; + private final Imap2SyncService mService; + + public AttachmentLoader(Imap2SyncService service, PartRequest req) { + mService = service; + mContext = service.mContext; + mAttachment = req.mAttachment; + mAttachmentId = mAttachment.mId; + mMessageId = mAttachment.mMessageKey; + mMessage = Message.restoreMessageWithId(mContext, mMessageId); + } + + private void doStatusCallback(int status) { + try { + Imap2SyncManager.callback().loadAttachmentStatus(mMessageId, mAttachmentId, status, 0); + } catch (RemoteException e) { + // No danger if the client is no longer around + } + } + + private void doProgressCallback(int progress) { + try { + Imap2SyncManager.callback().loadAttachmentStatus(mMessageId, mAttachmentId, + EmailServiceStatus.IN_PROGRESS, progress); + } catch (RemoteException e) { + // No danger if the client is no longer around + } + } + + /** + * Close, ignoring errors (as during cleanup) + * @param c a Closeable + */ + private void close(Closeable c) { + try { + c.close(); + } catch (IOException e) { + } + } + + /** + * Save away the contentUri for this Attachment and notify listeners + * @throws IOException + */ + private void finishLoadAttachment(File file, OutputStream os) throws IOException { + InputStream in = null; + try { + in = new FileInputStream(file); + if (mAttachment.mEncoding != null && + "base64".equals(mAttachment.mEncoding.toLowerCase())) { + in = new Base64InputStream(in); + } + AttachmentUtilities.saveAttachment(mContext, in, mAttachment); + doStatusCallback(EmailServiceStatus.SUCCESS); + } catch (FileNotFoundException e) { + // Not bloody likely, as we just created it successfully + throw new IOException("Attachment file not found?"); + } finally { + close(in); + } + } + + private void readPart (ImapInputStream in, String tag, OutputStream out) 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; + int br = res.indexOf('{'); + if (br > 0) { + Parser p = new Parser(res, br + 1); + int expectedLength = p.parseInteger(); + int remainingLength = expectedLength; + int totalRead = 0; + byte[] buf = new byte[CHUNK_SIZE]; + int lastCallbackPct = -1; + int lastCallbackTotalRead = 0; + while (remainingLength > 0) { + int rdlen = (remainingLength > CHUNK_SIZE ? CHUNK_SIZE : remainingLength); + int bytesRead = in.read(buf, 0, rdlen); + totalRead += bytesRead; + out.write(buf, 0, bytesRead); + remainingLength -= bytesRead; + int pct = (totalRead * 100) / expectedLength; + // Callback only if we've read at least 1% more and have read more than CHUNK_SIZE + // We don't want to spam the Email app + if ((pct > lastCallbackPct) && (totalRead > (lastCallbackTotalRead + CHUNK_SIZE))) { + // Report progress back to the UI + doProgressCallback(pct); + lastCallbackTotalRead = totalRead; + lastCallbackPct = pct; + } + } + out.close(); + String line = in.readLine(); + if (!line.endsWith(")")) { + mService.errorLog("Bad part?"); + throw new IOException(); + } + line = in.readLine(); + if (!line.startsWith(tag)) { + mService.userLog("Bad part?"); + throw new IOException(); + } + } + } + + /** + * Loads an attachment, based on the PartRequest passed in the constructor + * @throws IOException + */ + public void loadAttachment(Connection conn) throws IOException { + if (mMessage == null) { + doStatusCallback(EmailServiceStatus.MESSAGE_NOT_FOUND); + return; + } + if (mAttachment.mUiState == UIProvider.AttachmentState.SAVED) { + return; + } + // Say we've started loading the attachment + doProgressCallback(0); + + try { + OutputStream os = null; + File tmpFile = null; + try { + tmpFile = File.createTempFile("imap2_", "tmp", mContext.getCacheDir()); + os = new FileOutputStream(tmpFile); + String tag = mService.writeCommand(conn.writer, "uid fetch " + mMessage.mServerId + + " body[" + mAttachment.mLocation + ']'); + readPart(conn.reader, tag, os); + finishLoadAttachment(tmpFile, os); + return; + } catch (FileNotFoundException e) { + mService.errorLog("Can't get attachment; write file not found?"); + doStatusCallback(EmailServiceStatus.ATTACHMENT_NOT_FOUND); + } finally { + close(os); + if (tmpFile != null) { + tmpFile.delete(); + } + } + } catch (IOException e) { + // Report the error, but also report back to the service + doStatusCallback(EmailServiceStatus.CONNECTION_ERROR); + throw e; + } finally { + } + } +} diff --git a/src/com/android/email/imap2/EmailSyncAdapterService.java b/src/com/android/email/imap2/EmailSyncAdapterService.java new file mode 100644 index 000000000..4366d0f96 --- /dev/null +++ b/src/com/android/email/imap2/EmailSyncAdapterService.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2; + +import com.android.emailcommon.provider.EmailContent; +import com.android.emailcommon.provider.EmailContent.AccountColumns; +import com.android.emailcommon.provider.EmailContent.MailboxColumns; +import com.android.emailcommon.provider.Mailbox; + +import android.accounts.Account; +import android.accounts.OperationCanceledException; +import android.app.Service; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.SyncResult; +import android.database.Cursor; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; + +public class EmailSyncAdapterService extends Service { + private static final String TAG = "Imap2 EmailSyncAdapterService"; + private static SyncAdapterImpl sSyncAdapter = null; + private static final Object sSyncAdapterLock = new Object(); + + private static final String[] ID_PROJECTION = new String[] {EmailContent.RECORD_ID}; + private static final String ACCOUNT_AND_TYPE_INBOX = + MailboxColumns.ACCOUNT_KEY + "=? AND " + MailboxColumns.TYPE + '=' + Mailbox.TYPE_INBOX; + + public EmailSyncAdapterService() { + super(); + } + + private static class SyncAdapterImpl extends AbstractThreadedSyncAdapter { + private Context mContext; + + public SyncAdapterImpl(Context context) { + super(context, true /* autoInitialize */); + mContext = context; + } + + @Override + public void onPerformSync(Account account, Bundle extras, + String authority, ContentProviderClient provider, SyncResult syncResult) { + try { + EmailSyncAdapterService.performSync(mContext, account, extras, + authority, provider, syncResult); + } catch (OperationCanceledException e) { + } + } + } + + @Override + public void onCreate() { + super.onCreate(); + synchronized (sSyncAdapterLock) { + if (sSyncAdapter == null) { + sSyncAdapter = new SyncAdapterImpl(getApplicationContext()); + } + } + } + + @Override + public IBinder onBind(Intent intent) { + return sSyncAdapter.getSyncAdapterBinder(); + } + + /** + * Partial integration with system SyncManager; we tell our EAS ExchangeService to start an + * inbox sync when we get the signal from the system SyncManager. + */ + private static void performSync(Context context, Account account, Bundle extras, + String authority, ContentProviderClient provider, SyncResult syncResult) + throws OperationCanceledException { + ContentResolver cr = context.getContentResolver(); + Log.i(TAG, "performSync"); + + // Find the (EmailProvider) account associated with this email address + Cursor accountCursor = + cr.query(com.android.emailcommon.provider.Account.CONTENT_URI, + ID_PROJECTION, AccountColumns.EMAIL_ADDRESS + "=?", new String[] {account.name}, + null); + try { + if (accountCursor.moveToFirst()) { + long accountId = accountCursor.getLong(0); + // Now, find the inbox associated with the account + Cursor mailboxCursor = cr.query(Mailbox.CONTENT_URI, ID_PROJECTION, + ACCOUNT_AND_TYPE_INBOX, new String[] {Long.toString(accountId)}, null); + try { + if (mailboxCursor.moveToFirst()) { + Log.i(TAG, "Mail sync requested for " + account.name); + // Ask for a sync from our sync manager + //*** + //SyncServiceManager.serviceRequest(mailboxCursor.getLong(0), + // SyncServiceManager.SYNC_KICK); + } + } finally { + mailboxCursor.close(); + } + } + } finally { + accountCursor.close(); + } + } +}
\ No newline at end of file diff --git a/src/com/android/email/imap2/Imap2SyncManager.java b/src/com/android/email/imap2/Imap2SyncManager.java new file mode 100644 index 000000000..e52ee339a --- /dev/null +++ b/src/com/android/email/imap2/Imap2SyncManager.java @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.RemoteCallbackList; +import android.os.RemoteException; + +import com.android.emailcommon.Api; +import com.android.emailcommon.provider.Account; +import com.android.emailcommon.provider.EmailContent; +import com.android.emailcommon.provider.EmailContent.Attachment; +import com.android.emailcommon.provider.EmailContent.MailboxColumns; +import com.android.emailcommon.provider.HostAuth; +import com.android.emailcommon.provider.Mailbox; +import com.android.emailcommon.provider.ProviderUnavailableException; +import com.android.emailcommon.service.AccountServiceProxy; +import com.android.emailcommon.service.EmailServiceCallback; +import com.android.emailcommon.service.IEmailService; +import com.android.emailcommon.service.IEmailServiceCallback; +import com.android.emailcommon.service.IEmailServiceCallback.Stub; +import com.android.emailcommon.service.SearchParams; +import com.android.emailsync.AbstractSyncService; +import com.android.emailsync.PartRequest; +import com.android.emailsync.SyncManager; +import com.android.email.R; +import com.android.mail.providers.UIProvider; +import com.android.mail.providers.UIProvider.AccountCapabilities; +import com.android.mail.providers.UIProvider.LastSyncResult; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; + +public class Imap2SyncManager extends SyncManager { + + // Callbacks as set up via setCallback + private static final RemoteCallbackList<IEmailServiceCallback> mCallbackList = + new RemoteCallbackList<IEmailServiceCallback>(); + + private static final EmailServiceCallback sCallbackProxy = + new EmailServiceCallback(mCallbackList); + + private Intent mIntent; + + private static final String IMAP2_ACCOUNT_TYPE = "com.android.imap2"; + private static final String PROTOCOL = "imap2"; + + /** + * Create our EmailService implementation here. + */ + private final IEmailService.Stub mBinder = new IEmailService.Stub() { + + @Override + public int getApiLevel() { + return Api.LEVEL; + } + + @Override + public Bundle validate(HostAuth hostAuth) throws RemoteException { + return new Imap2SyncService(Imap2SyncManager.this, + new Mailbox()).validateAccount(hostAuth, Imap2SyncManager.this); + } + + @Override + public Bundle autoDiscover(String userName, String password) throws RemoteException { + return null; + } + + @Override + public void startSync(long mailboxId, boolean userRequest) throws RemoteException { + SyncManager imapService = INSTANCE; + if (imapService == null) return; + Imap2SyncService svc = (Imap2SyncService) imapService.mServiceMap.get(mailboxId); + if (svc == null) { + startManualSync(mailboxId, userRequest ? SYNC_UI_REQUEST : SYNC_SERVICE_START_SYNC, + null); + } else { + svc.ping(); + } + } + + @Override + public void stopSync(long mailboxId) throws RemoteException { + stopManualSync(mailboxId); + } + + @Override + public void loadAttachment(long attachmentId, boolean background) throws RemoteException { + Attachment att = Attachment.restoreAttachmentWithId(Imap2SyncManager.this, attachmentId); + log("loadAttachment " + attachmentId + ": " + att.mFileName); + sendMessageRequest(new PartRequest(att, null, null)); + } + + @Override + public void updateFolderList(long accountId) throws RemoteException { + //*** + //reloadFolderList(ImapService.this, accountId, false); + } + + @Override + public void hostChanged(long accountId) throws RemoteException { + SyncManager exchangeService = INSTANCE; + if (exchangeService == null) return; + ConcurrentHashMap<Long, SyncError> syncErrorMap = exchangeService.mSyncErrorMap; + // Go through the various error mailboxes + for (long mailboxId: syncErrorMap.keySet()) { + SyncError error = syncErrorMap.get(mailboxId); + // If it's a login failure, look a little harder + Mailbox m = Mailbox.restoreMailboxWithId(exchangeService, mailboxId); + // If it's for the account whose host has changed, clear the error + // If the mailbox is no longer around, remove the entry in the map + if (m == null) { + syncErrorMap.remove(mailboxId); + } else if (error != null && m.mAccountKey == accountId) { + error.fatal = false; + error.holdEndTime = 0; + } + } + // Stop any running syncs + exchangeService.stopAccountSyncs(accountId, true); + // Kick ExchangeService + kick("host changed"); + } + + @Override + public void setLogging(int flags) throws RemoteException { + // Protocol logging + //Eas.setUserDebug(flags); + // Sync logging + setUserDebug(flags); + } + + @Override + public void sendMeetingResponse(long messageId, int response) throws RemoteException { + // Not used in IMAP + } + + @Override + public void loadMore(long messageId) throws RemoteException { + } + + // The following three methods are not implemented in this version + @Override + public boolean createFolder(long accountId, String name) throws RemoteException { + return false; + } + + @Override + public boolean deleteFolder(long accountId, String name) throws RemoteException { + return false; + } + + @Override + public boolean renameFolder(long accountId, String oldName, String newName) + throws RemoteException { + return false; + } + + @Override + public void setCallback(IEmailServiceCallback cb) throws RemoteException { + mCallbackList.register(cb); + } + + @Override + public void deleteAccountPIMData(long accountId) throws RemoteException { + // Not required for IMAP + } + + @Override + public int searchMessages(long accountId, SearchParams params, long destMailboxId) + throws RemoteException { + SyncManager ssm = INSTANCE; + if (ssm == null) return 0; + Mailbox mailbox = Mailbox.restoreMailboxWithId(ssm, params.mMailboxId); + Imap2SyncService svc = new Imap2SyncService(ssm, mailbox); + setMailboxSyncStatus(destMailboxId, UIProvider.SyncStatus.USER_QUERY); + boolean ioError = false; + try { + return svc.searchMailbox(ssm, accountId, params, destMailboxId); + } catch (IOException e) { + ioError = true; + return 0; + } finally { + // Report ioError status back + setMailboxLastSyncResult(destMailboxId, + ioError ? LastSyncResult.CONNECTION_ERROR : LastSyncResult.SUCCESS); + setMailboxSyncStatus(destMailboxId, UIProvider.SyncStatus.NO_SYNC); + } + } + + @Override + public void sendMail(long accountId) throws RemoteException { + // Not required for IMAP + } + + @Override + public int getCapabilities(Account acct) throws RemoteException { + return AccountCapabilities.SYNCABLE_FOLDERS | + AccountCapabilities.FOLDER_SERVER_SEARCH | + AccountCapabilities.UNDO; + } + }; + + static public IEmailServiceCallback callback() { + return sCallbackProxy; + } + + @Override + public AccountObserver getAccountObserver(Handler handler) { + return new AccountObserver(handler) { + @Override + public void newAccount(long acctId) { + // Create the Inbox for the account if it doesn't exist + Context context = getContext(); + Account acct = Account.restoreAccountWithId(context, acctId); + if (acct == null) return; + long inboxId = Mailbox.findMailboxOfType(context, acctId, Mailbox.TYPE_INBOX); + if (inboxId != Mailbox.NO_MAILBOX) { + return; + } + Mailbox inbox = new Mailbox(); + inbox.mDisplayName = context.getString(R.string.mailbox_name_server_inbox); + inbox.mServerId = "Inbox"; + inbox.mAccountKey = acct.mId; + inbox.mType = Mailbox.TYPE_INBOX; + inbox.mSyncInterval = acct.mSyncInterval; + inbox.save(getContext()); + log("Creating inbox for account: " + acct.mDisplayName); + Imap2SyncManager.kick("New account"); + // Need to sync folder list first; sigh + Imap2SyncService svc = new Imap2SyncService(Imap2SyncManager.this, acct); + try { + svc.loadFolderList(); + mResolver.update( + ContentUris.withAppendedId(EmailContent.PICK_TRASH_FOLDER_URI, acctId), + new ContentValues(), null, null); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + } + }; + } + + @Override + public void onStartup() { + // No special behavior + } + + private static final String ACCOUNT_KEY_IN = MailboxColumns.ACCOUNT_KEY + " in ("; + private String mAccountSelector; + @Override + public String getAccountsSelector() { + if (mAccountSelector == null) { + StringBuilder sb = new StringBuilder(ACCOUNT_KEY_IN); + boolean first = true; + synchronized (mAccountList) { + for (Account account : mAccountList) { + if (!first) { + sb.append(','); + } else { + first = false; + } + sb.append(account.mId); + } + } + sb.append(')'); + mAccountSelector = sb.toString(); + } + return mAccountSelector; + } + + @Override + public AbstractSyncService getServiceForMailbox(Context context, Mailbox mailbox) { + return new Imap2SyncService(context, mailbox); + } + + @Override + public AccountList collectAccounts(Context context, AccountList accounts) { + ContentResolver resolver = context.getContentResolver(); + Cursor c = resolver.query(Account.CONTENT_URI, Account.CONTENT_PROJECTION, null, null, + null); + // We must throw here; callers might use the information we provide for reconciliation, etc. + if (c == null) throw new ProviderUnavailableException(); + try { + while (c.moveToNext()) { + long hostAuthId = c.getLong(Account.CONTENT_HOST_AUTH_KEY_RECV_COLUMN); + if (hostAuthId > 0) { + HostAuth ha = HostAuth.restoreHostAuthWithId(context, hostAuthId); + if (ha != null && ha.mProtocol.equals("imap2")) { + Account account = new Account(); + account.restore(c); + account.mHostAuthRecv = ha; + accounts.add(account); + } + } + } + } finally { + c.close(); + } + return accounts; + } + + @Override + public String getAccountManagerType() { + return IMAP2_ACCOUNT_TYPE; + } + + @Override + public Intent getServiceIntent() { + if (mIntent == null) { + mIntent = new Intent(this, Imap2SyncManager.class); + } + return mIntent; + } + + @Override + public Stub getCallbackProxy() { + return sCallbackProxy; + } + + @Override + protected void runAccountReconcilerSync(Context context) { + alwaysLog("Reconciling accounts..."); + new AccountServiceProxy(context).reconcileAccounts(PROTOCOL, getAccountManagerType()); + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onStartService(Mailbox mailbox) { + // No special behavior + } +} diff --git a/src/com/android/email/imap2/Imap2SyncService.java b/src/com/android/email/imap2/Imap2SyncService.java new file mode 100644 index 000000000..65a55b4c6 --- /dev/null +++ b/src/com/android/email/imap2/Imap2SyncService.java @@ -0,0 +1,2486 @@ +/* Copyright (C) 2012 The Android Open Source Project. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.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.net.Uri; +import android.os.Bundle; +import android.os.RemoteException; +import android.util.Log; + +import com.android.email.imap2.smtp.SmtpSender; +import com.android.emailcommon.Logging; +import com.android.emailcommon.TrafficFlags; +import com.android.emailcommon.internet.MimeUtility; +import com.android.emailcommon.internet.Rfc822Output; +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; +import com.android.emailcommon.provider.EmailContent.AccountColumns; +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.Message; +import com.android.emailcommon.provider.EmailContent.MessageColumns; +import com.android.emailcommon.provider.EmailContent.SyncColumns; +import com.android.emailcommon.provider.HostAuth; +import com.android.emailcommon.provider.Mailbox; +import com.android.emailcommon.provider.MailboxUtilities; +import com.android.emailcommon.service.EmailServiceProxy; +import com.android.emailcommon.service.EmailServiceStatus; +import com.android.emailcommon.service.SearchParams; +import com.android.emailcommon.service.SyncWindow; +import com.android.emailcommon.utility.CountingOutputStream; +import com.android.emailcommon.utility.EOLConvertingOutputStream; +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 com.google.common.annotations.VisibleForTesting; + +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.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +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 String IMAP_NO = "NO"; + private static final String IMAP_BAD = "BAD"; + + 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 = 20; + + 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 IDLE_FALLBACK_SYNC_INTERVAL = 10; + + private static final int SOCKET_CONNECT_TIMEOUT = 10*SECONDS; + private static final int SOCKET_TIMEOUT = 20*SECONDS; + private static final int SEARCH_TIMEOUT = 60*SECONDS; + + private static final int AUTOMATIC_SYNC_WINDOW_MAX_MESSAGES = 250; + private static final int AUTOMATIC_SYNC_WINDOW_LARGE_MAILBOX = 1000; + + private ContentResolver mResolver; + private int mWriterTag = 1; + private boolean mIsGmail = false; + private boolean mIsIdle = false; + private int mLastExists = -1; + + private ArrayList<String> mImapResponse = null; + private String mImapResult; + private String mImapErrorLine = null; + private String mImapSuccessLine = 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<Integer> SERVER_DELETES = new ArrayList<Integer>(); + + private static final String INBOX_SERVER_NAME = "Inbox"; // Per RFC3501 + + private BufferedWriter mWriter; + private ImapInputStream mReader; + + private HostAuth mHostAuth; + private String mPrefix; + private long mTrashMailboxId = Mailbox.NO_MAILBOX; + private long mAccountId; + + private final ArrayList<Long> mUpdatedIds = new ArrayList<Long>(); + private final ArrayList<Long> mDeletedIds = new ArrayList<Long>(); + private final Stack<Integer> mDeletes = new Stack<Integer>(); + private final Stack<Integer> mReadUpdates = new Stack<Integer>(); + private final Stack<Integer> mUnreadUpdates = new Stack<Integer>(); + private final Stack<Integer> mFlaggedUpdates = new Stack<Integer>(); + private final Stack<Integer> mUnflaggedUpdates = new Stack<Integer>(); + + public Imap2SyncService(Context _context, Mailbox _mailbox) { + super(_context, _mailbox); + mResolver = _context.getContentResolver(); + if (mAccount != null) { + mAccountId = mAccount.mId; + } + 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; + mAccountId = _account.mId; + mHostAuth = HostAuth.restoreHostAuthWithId(_context, mAccount.mHostAuthKeyRecv); + mPrefix = mHostAuth.mDomain; + mTrashMailboxId = Mailbox.findMailboxOfType(_context, _account.mId, Mailbox.TYPE_TRASH); + } + + @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; + } + + // Report the error back to the UI... + String alert = getAlert(); + if (alert != null) { + bundle.putString(EmailServiceProxy.VALIDATE_BUNDLE_ERROR_MESSAGE, alert); + } + + 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) throws IOException { + Integer t = mWriterTag++; + String tag = "@@a" + t + ' '; + if (!cmd.startsWith("login")) { + userLog(tag + cmd); + } + out.write(tag); + out.write(cmd); + out.write("\r\n"); + out.flush(); + return tag; + } + + private void writeContinuation(Writer out, String cmd) { + try { + out.write(cmd); + out.write("\r\n"); + out.flush(); + userLog(cmd); + } catch (IOException e) { + userLog("IOException in writeCommand"); + } + } + + 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 != null && (mMailbox.mSyncKey == null || mMailbox.mSyncKey == "0")) { + str = str.toLowerCase(); + int idx = str.indexOf("uidvalidity"); + if (idx > 0) { + // 12 = length of "uidvalidity" + 1 + 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<String>(); + 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 (str.charAt(0) == '+') { + mImapResult = str; + return 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)) { + mImapSuccessLine = str; + } else { + userLog("$$$ Error result = " + mImapResult); + mImapErrorLine = str; + } + return mImapResult; + } + + String parseRecipientList (String str) { + if (str == null) + return null; + ArrayList<Address> list = new ArrayList<Address>(); + 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, long mailboxId) { + 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; + + Message msg = new Message(); + msg.mMailboxKey = mailboxId; + + try { + while (true) { + String atm = p.parseAtom(); + // 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(); + } 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 = mAccountId; + + 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); + + // If we're not storing to the same mailbox (search), save away our mailbox name + if (mailboxId != mMailboxId) { + msg.mProtocolSearchInfo = mMailboxName; + } + 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 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(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<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); + 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; + } + + Integer update = 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<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); + for (Long id: mUpdatedIds) { + ops.add(ContentProviderOperation.newDelete( + ContentUris.withAppendedId(Message.UPDATED_CONTENT_URI, id)).build()); + } + applyBatch(ops); + } + } + + private void sendUpdate(Stack<Integer> updates, String command) throws IOException { + // First, generate the appropriate String + while (!updates.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < HEADER_BATCH_COUNT && !updates.empty(); i++) { + Integer update = updates.pop(); + if (i != 0) { + sb.append(','); + } + sb.append(update); + } + 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<Message> msgList) { + // Get the ids of updated messages in this mailbox (usually there won't be any) + Cursor c = getUpdatesCursor(); + ArrayList<Integer> updatedIds = new ArrayList<Integer>(); + boolean newUpdates = false; + + if (c != null) { + try { + if (c.moveToFirst()) { + do { + updatedIds.add(c.getInt(UPDATE_DELETE_SERVER_ID_COLUMN)); + newUpdates = true; + } while (c.moveToNext()); + } + } finally { + c.close(); + } + } + + ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); + for (Message msg: msgList) { + // If the message is updated, make sure it's not deleted (we don't want to reload it) + if (newUpdates && updatedIds.contains(msg.mServerId)) { + Message currentMsg = Message.restoreMessageWithId(mContext, msg.mId); + if (currentMsg.mMailboxKey == mTrashMailboxId) { + userLog("PHEW! Didn't save deleted message with uid: " + msg.mServerId); + continue; + } + } + // Add the CPO's for this message + msg.addSaveOps(ops); + } + + // Commit these messages + 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 BodyThread mBodyThread; + private Connection mConnection; + + private void parseBodystructure (Message msg, Parser p, String level, int cnt, + ArrayList<Attachment> viewables, ArrayList<Attachment> 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<Attachment> viewables = new ArrayList<Attachment>(); + ArrayList<Attachment> attachments = new ArrayList<Attachment>(); + 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 = mAccountId; + 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; + } + } + } + } + + /** + * Class that loads message bodies in its own thread + */ + private class BodyThread extends Thread { + final Connection mConnection; + final Cursor mCursor; + + BodyThread(Connection conn, Cursor cursor) { + super(); + mConnection = conn; + mCursor = cursor; + } + + public void run() { + try { + fetchMessageData(mConnection, mCursor); + } catch (IOException e) { + userLog("IOException in body thread; closing..."); + } finally { + mConnection.close(); + mBodyThread = null; + } + } + + void close() { + mConnection.close(); + } + } + + 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"); + if (unloaded == null) return; + 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 BodyThread(conn, unloaded); + mBodyThread.start(); + userLog("***** Starting mBodyThread " + mBodyThread.getId()); + } else { + // fetchMessageData closes the cursor + fetchMessageData(mConnection, unloaded); + } + } else { + // fetchMessageData closes the cursor + fetchMessageData(mConnection, unloaded); + } + } else { + unloaded.close(); + } + } + + void readFolderList () throws IOException { + String tag = writeCommand(mWriter, "list \"\" *"); + String line; + char dchar = '/'; + + userLog("Loading folder list..."); + + ArrayList<String> parentList = new ArrayList<String>(); + ArrayList<Mailbox> mailboxList = new ArrayList<Mailbox>(); + 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 = decodeFolderName(displayName, null); + m.mAccountKey = mAccountId; + m.mServerId = decodeFolderName(serverId, null); + 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(mAccountId)}, + 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<Mailbox> addList = new ArrayList<Mailbox>(); + + 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<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); + 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 + "=" + mAccountId); + } finally { + SyncManager.kick("folder list"); + } + } + + 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 static final int BATCH_SIZE = 100; + private void applyBatch(ArrayList<ContentProviderOperation> ops) { + try { + int len = ops.size(); + if (len == 0) { + return; + } else if (len < BATCH_SIZE) { + mResolver.applyBatch(EmailContent.AUTHORITY, ops); + } else { + ArrayList<ContentProviderOperation> batchOps = + new ArrayList<ContentProviderOperation>(); + for (int i = 0; i < len; i+=BATCH_SIZE) { + batchOps.clear(); + for (int j = 0; (j < BATCH_SIZE) && ((i+j) < len); j++) { + batchOps.add(ops.get(i+j)); + } + mResolver.applyBatch(EmailContent.AUTHORITY, batchOps); + } + } + } catch (RemoteException e) { + // Nothing to be done + } catch (OperationApplicationException e) { + // These operations are legal; this can't really happen + } + } + + private void processServerDeletes(ArrayList<Integer> deleteList) { + int cnt = deleteList.size(); + if (cnt > 0) { + ArrayList<ContentProviderOperation> ops = + new ArrayList<ContentProviderOperation>(); + for (int i = 0; i < cnt; i++) { + MAILBOX_SERVER_ID_ARGS[1] = Long.toString(deleteList.get(i)); + Builder b = ContentProviderOperation.newDelete( + Message.SELECTED_MESSAGE_CONTENT_URI); + b.withSelection(MessageColumns.MAILBOX_KEY + "=? AND " + + SyncColumns.SERVER_ID + "=?", MAILBOX_SERVER_ID_ARGS); + ops.add(b.build()); + } + applyBatch(ops); + } + } + + private void processIntegers(ArrayList<Integer> deleteList, ContentValues values) { + int cnt = deleteList.size(); + if (cnt > 0) { + ArrayList<ContentProviderOperation> ops = + new ArrayList<ContentProviderOperation>(); + for (int i = 0; i < cnt; i++) { + MAILBOX_SERVER_ID_ARGS[1] = Long.toString(deleteList.get(i)); + Builder b = ContentProviderOperation.newUpdate( + Message.SELECTED_MESSAGE_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<Integer> insert; + ArrayList<Integer> delete; + + Reconciled (ArrayList<Integer> ins, ArrayList<Integer> del) { + insert = ins; + delete = del; + } + } + + // Arrays must be sorted beforehand + public Reconciled reconcile (String name, int[] deviceList, int[] serverList) { + ArrayList<Integer> loadList = new ArrayList<Integer>(); + ArrayList<Integer> deleteList = new ArrayList<Integer>(); + 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); + processIntegers(r.delete, values); + values.put(column, !sense); + processIntegers(r.insert, values); + } + } + + private ArrayList<String> getTokens(String str) { + ArrayList<String> tokens = new ArrayList<String>(); + Parser p = new Parser(str); + while(true) { + String capa = p.parseAtom(); + if (capa == null) { + break; + } + tokens.add(capa); + } + return tokens; + } + + /** + * Convenience class to hold state for a single IMAP connection + */ + public static class Connection { + Socket socket; + int status; + String reason; + ImapInputStream reader; + BufferedWriter writer; + + void close() { + try { + socket.close(); + } catch (IOException e) { + // It's all good + } + } + } + + private String mUserAgent; + + private String getAlert() { + if (IMAP_NO.equals(mImapResult)) { + int alertPos = mImapErrorLine.indexOf("[ALERT]"); + if (alertPos > 0) { + return mImapErrorLine.substring(alertPos + 7); + } + } + return null; + } + + private Connection connectAndLogin(HostAuth hostAuth, String name) { + return connectAndLogin(hostAuth, name, null); + } + + private Connection connectAndLogin(HostAuth hostAuth, String name, Socket tlsSocket) { + Connection conn = new Connection(); + Socket socket; + try { + if (tlsSocket != null) { + // Start secure connection on top of existing one + boolean trust = (hostAuth.mFlags & HostAuth.FLAG_TRUST_ALL) != 0; + socket = SSLUtils.getSSLSocketFactory(mContext, hostAuth, trust) + .createSocket(tlsSocket, hostAuth.mAddress, hostAuth.mPort, true); + + } else { + socket = getSocket(hostAuth); + } + socket.setSoTimeout(SOCKET_TIMEOUT); + userLog(">>> IMAP CONNECTION SUCCESSFUL: " + name + + ((socket != null) ? " [STARTTLS]" : "")); + + ImapInputStream reader = new ImapInputStream(socket.getInputStream()); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( + socket.getOutputStream())); + // Get welcome string + if (tlsSocket == null) { + 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<String> tokens = getTokens(capa); + if (tokens.contains("starttls") && tlsSocket == null && + ((hostAuth.mFlags & HostAuth.FLAG_SSL) == 0)) { + userLog("[Use STARTTLS]"); + tag = writeCommand(writer, "STARTTLS"); + readResponse(reader, tag, "STARTTLS"); + return connectAndLogin(hostAuth, name, socket); + } + 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 (!IMAP_OK.equals(readResponse(reader, tag))) { + // Fine if the alert is null + conn.reason = getAlert(); + 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); + } + + /** + * Reset the sync interval for this mailbox (account if it's Inbox) + */ + private void resetSyncInterval(int minutes) { + ContentValues values = new ContentValues(); + Uri uri; + if (mMailbox.mType == Mailbox.TYPE_INBOX) { + values.put(AccountColumns.SYNC_INTERVAL, minutes); + uri = ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId); + } else { + values.put(MailboxColumns.SYNC_INTERVAL, minutes); + uri = ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId); + } + // Reset this so that we won't loop + mMailbox.mSyncInterval = minutes; + // Update the mailbox/account with new sync interval + mResolver.update(uri, 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); + if (resp.contains(IMAP_BAD)) { + // Fatal error (server doesn't support this command) + userLog("IDLE not supported; falling back to scheduled sync"); + resetSyncInterval(IDLE_FALLBACK_SYNC_INTERVAL); + } + 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(SOCKET_TIMEOUT); + } + mIsIdle = false; + mThread.setName(mMailboxName + "[" + mAccount.mDisplayName + "]"); + } + } + + private void doUpload(long messageId, String mailboxServerId) throws IOException, + MessagingException { + ContentValues values = new ContentValues(); + CountingOutputStream out = new CountingOutputStream(); + EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(out); + Rfc822Output.writeTo(mContext, + messageId, + eolOut, + false /* do not use smart reply */, + false /* do not send BCC */); + eolOut.flush(); + long len = out.getCount(); + try { + String tag = writeCommand(mWriter, "append \"" + + encodeFolderName(mailboxServerId) + + "\" (\\seen) {" + len + '}'); + String line = mReader.readLine(); + if (line.startsWith("+")) { + userLog("append response: " + line); + eolOut = new EOLConvertingOutputStream(mSocket.getOutputStream()); + Rfc822Output.writeTo(mContext, + messageId, + eolOut, + false /* do not use smart reply */, + false /* do not send BCC */); + eolOut.flush(); + mWriter.write("\r\n"); + mWriter.flush(); + if (readResponse(mConnection.reader, tag).equals(IMAP_OK)) { + int serverId = 0; + String lc = mImapSuccessLine.toLowerCase(); + int appendUid = lc.indexOf("appenduid"); + if (appendUid > 0) { + Parser p = new Parser(lc, appendUid + 11); + // UIDVALIDITY (we don't need it) + p.parseInteger(); + serverId = p.parseInteger(); + } + values.put(SyncColumns.SERVER_ID, serverId); + mResolver.update(ContentUris.withAppendedId(Message.CONTENT_URI, + messageId), values, null, null); + } else { + userLog("Append failed: " + mImapErrorLine); + } + } else { + userLog("Append failed: " + line); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void processUploads() { + Mailbox sentMailbox = Mailbox.restoreMailboxOfType(mContext, mAccountId, Mailbox.TYPE_SENT); + if (sentMailbox == null) { + // Nothing to do this time around; we'll check each time through the sync loop + return; + } + Cursor c = mResolver.query(Message.CONTENT_URI, Message.ID_COLUMN_PROJECTION, + MessageColumns.MAILBOX_KEY + "=? AND " + SyncColumns.SERVER_ID + " is null", + new String[] {Long.toString(sentMailbox.mId)}, null); + if (c != null) { + String sentMailboxServerId = sentMailbox.mServerId; + try { + // Upload these messages + while (c.moveToNext()) { + try { + doUpload(c.getLong(Message.ID_COLUMNS_ID_COLUMN), sentMailboxServerId); + } catch (IOException e) { + e.printStackTrace(); + } catch (MessagingException e) { + e.printStackTrace(); + } + } + } finally { + c.close(); + } + } + } + + private int[] getServerIds(String since) throws IOException { + String tag = writeCommand(mWriter, "uid search undeleted since " + since); + + if (!readResponse(mReader, tag, "SEARCH").equals(IMAP_OK)) { + userLog("$$$ WHOA! Search failed? "); + return null; + } + + userLog(">>> SEARCH RESULT"); + String msgs; + Parser p; + if (mImapResponse.isEmpty()) { + return new int[0]; + } else { + msgs = mImapResponse.get(0); + // Length of "* search" + p = new Parser(msgs, 8); + return p.gatherInts(); + } + } + + static private final int[] AUTO_WINDOW_VALUES = new int[] { + SyncWindow.SYNC_WINDOW_ALL, SyncWindow.SYNC_WINDOW_1_MONTH, SyncWindow.SYNC_WINDOW_2_WEEKS, + SyncWindow.SYNC_WINDOW_1_WEEK, SyncWindow.SYNC_WINDOW_3_DAYS}; + + /** + * Determine a sync window for this mailbox by trying different possibilities from among the + * allowed values (in AUTO_WINDOW_VALUES). We start testing with "all" unless there are more + * than AUTOMATIC_SYNC_WINDOW_LARGE_MAILBOX messages (we really don't want to load that many); + * otherwise, we start with one month. We'll pick any value that has fewer than + * AUTOMATIC_SYNC_WINDOW_MAX_MESSAGES messages (arbitrary, but reasonable) + * @return a reasonable sync window for this mailbox + * @throws IOException + */ + private int getAutoSyncWindow() throws IOException { + int i = (mLastExists > AUTOMATIC_SYNC_WINDOW_LARGE_MAILBOX) ? 1 : 0; + for (; i < AUTO_WINDOW_VALUES.length; i++) { + int window = AUTO_WINDOW_VALUES[i]; + long days = SyncWindow.toDays(window); + Date date = new Date(System.currentTimeMillis() - (days*DAYS)); + String since = IMAP_DATE_FORMAT.format(date); + int msgCount = getServerIds(since).length; + if (msgCount < AUTOMATIC_SYNC_WINDOW_MAX_MESSAGES) { + userLog("getAutoSyncWindow returns " + days + " days."); + return window; + } + } + userLog("getAutoSyncWindow returns 1 day."); + return SyncWindow.SYNC_WINDOW_1_DAY; + } + + /** + * Process our list of requested attachment loads + * @throws IOException + */ + 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 loadMessages(ArrayList<Integer> loadList, long mailboxId) throws IOException { + int idx= 1; + boolean loadedSome = false; + int cnt = loadList.size(); + while (idx <= cnt) { + ArrayList<Message> tmsgList = new ArrayList<Message> (); + 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)"); + String 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), mailboxId); + tmsgList.add(msg); + } + saveNewMessages(tmsgList); + } + + fetchMessageData(); + loadedSome = true; + } + // TODO: Use loader to watch for changes on unloaded body cursor + if (!loadedSome) { + fetchMessageData(); + } + } + + 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; + mExitReason = conn.reason; + 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(); + + // We'll use 14 days as the "default" + long days = 14; + int lookback = mMailbox.mSyncLookback; + if (mMailbox.mType == Mailbox.TYPE_INBOX) { + lookback = mAccount.mSyncLookback; + } + if (lookback == SyncWindow.SYNC_WINDOW_AUTO) { + if (mLastExists >= 0) { + ContentValues values = new ContentValues(); + lookback = getAutoSyncWindow(); + Uri uri; + if (mMailbox.mType == Mailbox.TYPE_INBOX) { + values.put(AccountColumns.SYNC_LOOKBACK, lookback); + uri = ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId); + } else { + values.put(MailboxColumns.SYNC_LOOKBACK, lookback); + uri = ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId); + } + mResolver.update(uri, values, null, null); + } + } + if (lookback != SyncWindow.SYNC_WINDOW_UNKNOWN) { + days = SyncWindow.toDays(lookback); + } + + Date date = new Date(System.currentTimeMillis() - (days*DAYS)); + String since = IMAP_DATE_FORMAT.format(date); + int[] serverList = getServerIds(since); + if (serverList == null) { + // Do backoff; hope it works next time. Should never happen + mExitStatus = EXIT_IO_ERROR; + return; + } + + Arrays.sort(serverList); + int[] deviceList = getUidList(null); + Reconciled r = + reconcile("MESSAGES", deviceList, serverList); + ArrayList<Integer> loadList = r.insert; + ArrayList<Integer> deleteList = r.delete; + serverList = null; + deviceList = null; + + // We load message headers in batches + loadMessages(loadList, mMailboxId); + + // 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); + + processUploads(); + + // 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 (mConnection != null) { + try { + // Try to logout + readResponse(mReader, writeCommand(mWriter, "logout")); + mConnection.close(); + } catch (IOException e) { + // We're leaving anyway + } + } + } + } + } + + private void sendMail() { + long sentMailboxId = Mailbox.findMailboxOfType(mContext, mAccountId, Mailbox.TYPE_SENT); + if (sentMailboxId == Mailbox.NO_MAILBOX) { + // The user must choose a sent mailbox + mResolver.update( + ContentUris.withAppendedId(EmailContent.PICK_SENT_FOLDER_URI, mAccountId), + new ContentValues(), null, null); + } + Account account = Account.restoreAccountWithId(mContext, mAccountId); + if (account == null) { + return; + } + TrafficStats.setThreadStatsTag(TrafficFlags.getSmtpFlags(mContext, account)); + // 1. Loop through all messages in the account's outbox + long outboxId = Mailbox.findMailboxOfType(mContext, account.mId, Mailbox.TYPE_OUTBOX); + if (outboxId == Mailbox.NO_MAILBOX) { + return; + } + Cursor c = mResolver.query(Message.CONTENT_URI, Message.ID_COLUMN_PROJECTION, + Message.MAILBOX_KEY + "=?", new String[] { Long.toString(outboxId) }, null); + ContentValues values = new ContentValues(); + values.put(MessageColumns.MAILBOX_KEY, sentMailboxId); + try { + // 2. exit early + if (c.getCount() <= 0) { + return; + } + + SmtpSender sender = new SmtpSender(mContext, account, mUserLog); + + // 3. loop through the available messages and send them + while (c.moveToNext()) { + long messageId = -1; + try { + messageId = c.getLong(Message.ID_COLUMNS_ID_COLUMN); + // Don't send messages with unloaded attachments + if (Utility.hasUnloadedAttachments(mContext, messageId)) { + userLog("Can't send #" + messageId + "; unloaded attachments"); + continue; + } + sender.sendMessage(messageId); + // Move to sent folder + mResolver.update(ContentUris.withAppendedId(Message.CONTENT_URI, messageId), + values, null, null); + } catch (MessagingException me) { + continue; + } + } + } finally { + c.close(); + } + } + + @Override + public void run() { + try { + TAG = Thread.currentThread().getName(); + + // Check for Outbox (special "sync") and stopped + if (mMailbox.mType == Mailbox.TYPE_OUTBOX) { + sendMail(); + mExitStatus = EXIT_DONE; + return; + } else if (mStop) { + return; + } + + 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 Imap2SyncService", 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 we close our body thread (if any) + if (mBodyThread != null) { + mBodyThread.close(); + } + + // Make sure ExchangeService knows about this + Imap2SyncManager.kick("sync finished"); + } + } + + 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(mContext, hostAuth, 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. + * + * <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 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); + } + } + + /** + * Cache search results by account; this allows for "load more" support without having to + * redo the search (which can be quite slow). + */ + private static final HashMap<Long, Integer[]> sSearchResults = new HashMap<Long, Integer[]>(); + + @VisibleForTesting + protected static boolean isAsciiString(String str) { + int len = str.length(); + for (int i = 0; i < len; i++) { + char c = str.charAt(i); + if (c >= 128) return false; + } + return true; + } + + /** + * Wrapper for a search result with possible exception (to be sent back to the UI) + */ + private static class SearchResult { + Integer[] uids; + Exception exception; + + SearchResult(Integer[] _uids, Exception _exception) { + uids = _uids; + exception = _exception; + } + } + + private SearchResult getSearchResults(SearchParams searchParams) { + String filter = searchParams.mFilter; + // All servers MUST accept US-ASCII, so we'll send this as the CHARSET unless we're really + // dealing with a string that contains non-ascii characters + String charset = "US-ASCII"; + if (!isAsciiString(filter)) { + charset = "UTF-8"; + } + List<String> commands = new ArrayList<String>(); + // This is the length of the string in octets (bytes), formatted as a string literal {n} + String octetLength = "{" + filter.getBytes().length + "}"; + // Break the command up into pieces ending with the string literal length + commands.add("UID SEARCH CHARSET " + charset + " OR FROM " + octetLength); + commands.add(filter + " (OR TO " + octetLength); + commands.add(filter + " (OR CC " + octetLength); + commands.add(filter + " (OR SUBJECT " + octetLength); + commands.add(filter + " BODY " + octetLength); + commands.add(filter + ")))"); + + Exception exception = null; + try { + int len = commands.size(); + String tag = null; + for (int i = 0; i < len; i++) { + String command = commands.get(i); + if (i == 0) { + mSocket.setSoTimeout(SEARCH_TIMEOUT); + tag = writeCommand(mWriter, command); + } else { + writeContinuation(mWriter, command); + } + if (readResponse(mReader, tag, "SEARCH").equals(IMAP_OK)) { + // Done + String msgs = mImapResponse.get(0); + Parser p = new Parser(msgs, 8); + Integer[] serverList = p.gatherIntegers(); + Arrays.sort(serverList, Collections.reverseOrder()); + return new SearchResult(serverList, null); + } else if (mImapResult.startsWith("+")){ + continue; + } else { + errorLog("Server doesn't understand complex SEARCH?"); + break; + } + } + } catch (SocketTimeoutException e) { + exception = e; + errorLog("Search timed out"); + } catch (IOException e) { + exception = e; + errorLog("Search IOException"); + } + return new SearchResult(new Integer[0], exception); + } + + public int searchMailbox(final Context context, long accountId, SearchParams searchParams, + final long destMailboxId) throws IOException { + final Account account = Account.restoreAccountWithId(context, accountId); + final Mailbox mailbox = Mailbox.restoreMailboxWithId(context, searchParams.mMailboxId); + final Mailbox destMailbox = Mailbox.restoreMailboxWithId(context, destMailboxId); + if (account == null || mailbox == null || destMailbox == null) { + Log.d(Logging.LOG_TAG, "Attempted search for " + searchParams + + " but account or mailbox information was missing"); + return 0; + } + HostAuth hostAuth = HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv); + if (hostAuth == null) { + } + + Connection conn = connectAndLogin(hostAuth, "search"); + if (conn.status != EXIT_DONE) { + mExitStatus = conn.status; + return 0; + } + try { + setConnection(conn); + + Integer[] sortedUids = null; + if (searchParams.mOffset == 0) { + SearchResult result = getSearchResults(searchParams); + if (result.exception == null) { + sortedUids = result.uids; + sSearchResults.put(accountId, sortedUids); + } else { + throw new IOException(); + } + } else { + sortedUids = sSearchResults.get(accountId); + } + + final int numSearchResults = sortedUids.length; + final int numToLoad = + Math.min(numSearchResults - searchParams.mOffset, searchParams.mLimit); + if (numToLoad <= 0) { + return 0; + } + + final ArrayList<Integer> loadList = new ArrayList<Integer>(); + for (int i = searchParams.mOffset; i < numToLoad + searchParams.mOffset; i++) { + loadList.add(sortedUids[i]); + } + try { + loadMessages(loadList, destMailboxId); + } catch (IOException e) { + // TODO: How do we handle this? + return 0; + } + + return sortedUids.length; + } finally { + if (mSocket != null) { + try { + // Try to logout + readResponse(mReader, writeCommand(mWriter, "logout")); + mSocket.close(); + } catch (IOException e) { + // We're leaving anyway + } + } + } + } +} + + diff --git a/src/com/android/email/imap2/ImapId.java b/src/com/android/email/imap2/ImapId.java new file mode 100644 index 000000000..47c7431d7 --- /dev/null +++ b/src/com/android/email/imap2/ImapId.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2; + +import android.content.Context; +import android.os.Build; +import android.telephony.TelephonyManager; +import android.util.Base64; +import android.util.Log; + +import com.android.emailcommon.Device; +import com.android.emailcommon.Logging; +import com.android.emailcommon.VendorPolicyLoader; +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.regex.Pattern; + +public class ImapId { + private static String sImapId; + + /** + * Return, or create and return, an string suitable for use in an IMAP ID message. + * This is constructed similarly to the way the browser sets up its user-agent strings. + * See RFC 2971 for more details. The output of this command will be a series of key-value + * pairs delimited by spaces (there is no point in returning a structured result because + * this will be sent as-is to the IMAP server). No tokens, parenthesis or "ID" are included, + * because some connections may append additional values. + * + * The following IMAP ID keys may be included: + * name Android package name of the program + * os "android" + * os-version "version; model; build-id" + * vendor Vendor of the client/server + * x-android-device-model Model (only revealed if release build) + * x-android-net-operator Mobile network operator (if known) + * AGUID A device+account UID + * + * In addition, a vendor policy .apk can append key/value pairs. + * + * @param userName the username of the account + * @param host the host (server) of the account + * @param capabilities a list of the capabilities from the server + * @return a String for use in an IMAP ID message. + */ + public static String getImapId(Context context, String userName, String host, + String capabilities) { + // The first section is global to all IMAP connections, and generates the fixed + // values in any IMAP ID message + synchronized (ImapId.class) { + if (sImapId == null) { + TelephonyManager tm = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + String networkOperator = tm.getNetworkOperatorName(); + if (networkOperator == null) networkOperator = ""; + + sImapId = makeCommonImapId(context.getPackageName(), Build.VERSION.RELEASE, + Build.VERSION.CODENAME, Build.MODEL, Build.ID, Build.MANUFACTURER, + networkOperator); + } + } + + // This section is per Store, and adds in a dynamic elements like UID's. + // We don't cache the result of this work, because the caller does anyway. + StringBuilder id = new StringBuilder(sImapId); + + // Optionally add any vendor-supplied id keys + String vendorId = VendorPolicyLoader.getInstance(context).getImapIdValues(userName, host, + capabilities); + if (vendorId != null) { + id.append(' '); + id.append(vendorId); + } + + // Generate a UID that mixes a "stable" device UID with the email address + try { + String devUID; + try { + devUID = Device.getDeviceId(context); + } catch (IOException e) { + // This would only happen with file system failure; it's fine to generate one + devUID = "_dev" + System.currentTimeMillis(); + } + MessageDigest messageDigest; + messageDigest = MessageDigest.getInstance("SHA-1"); + messageDigest.update(userName.getBytes()); + messageDigest.update(devUID.getBytes()); + byte[] uid = messageDigest.digest(); + String hexUid = Base64.encodeToString(uid, Base64.NO_WRAP); + id.append(" \"AGUID\" \""); + id.append(hexUid); + id.append('\"'); + } catch (NoSuchAlgorithmException e) { + Log.d(Logging.LOG_TAG, "couldn't obtain SHA-1 hash for device UID"); + } + return id.toString(); + } + + /** + * Helper function that actually builds the static part of the IMAP ID string. This is + * separated from getImapId for testability. There is no escaping or encoding in IMAP ID so + * any rogue chars must be filtered here. + * + * @param packageName context.getPackageName() + * @param version Build.VERSION.RELEASE + * @param codeName Build.VERSION.CODENAME + * @param model Build.MODEL + * @param id Build.ID + * @param vendor Build.MANUFACTURER + * @param networkOperator TelephonyManager.getNetworkOperatorName() + * @return the static (never changes) portion of the IMAP ID + */ + @VisibleForTesting + static String makeCommonImapId(String packageName, String version, + String codeName, String model, String id, String vendor, String networkOperator) { + + // Before building up IMAP ID string, pre-filter the input strings for "legal" chars + // This is using a fairly arbitrary char set intended to pass through most reasonable + // version, model, and vendor strings: a-z A-Z 0-9 - _ + = ; : . , / <space> + // The most important thing is *not* to pass parens, quotes, or CRLF, which would break + // the format of the IMAP ID list. + Pattern p = Pattern.compile("[^a-zA-Z0-9-_\\+=;:\\.,/ ]"); + packageName = p.matcher(packageName).replaceAll(""); + version = p.matcher(version).replaceAll(""); + codeName = p.matcher(codeName).replaceAll(""); + model = p.matcher(model).replaceAll(""); + id = p.matcher(id).replaceAll(""); + vendor = p.matcher(vendor).replaceAll(""); + networkOperator = p.matcher(networkOperator).replaceAll(""); + + // "name" "com.android.email" + StringBuffer sb = new StringBuffer("\"name\" \""); + sb.append(packageName); + sb.append("\""); + + // "os" "android" + sb.append(" \"os\" \"android\""); + + // "os-version" "version; build-id" + sb.append(" \"os-version\" \""); + if (version.length() > 0) { + sb.append(version); + } else { + // default to "1.0" + sb.append("1.0"); + } + // add the build ID or build # + if (id.length() > 0) { + sb.append("; "); + sb.append(id); + } + sb.append("\""); + + // "vendor" "the vendor" + if (vendor.length() > 0) { + sb.append(" \"vendor\" \""); + sb.append(vendor); + sb.append("\""); + } + + // "x-android-device-model" the device model (on release builds only) + if ("REL".equals(codeName)) { + if (model.length() > 0) { + sb.append(" \"x-android-device-model\" \""); + sb.append(model); + sb.append("\""); + } + } + + // "x-android-mobile-net-operator" "name of network operator" + if (networkOperator.length() > 0) { + sb.append(" \"x-android-mobile-net-operator\" \""); + sb.append(networkOperator); + sb.append("\""); + } + + return sb.toString(); + } + +} diff --git a/src/com/android/email/imap2/ImapInputStream.java b/src/com/android/email/imap2/ImapInputStream.java new file mode 100644 index 000000000..9fe080900 --- /dev/null +++ b/src/com/android/email/imap2/ImapInputStream.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class ImapInputStream extends FilterInputStream { + + public ImapInputStream(InputStream in) { + super(in); + } + + public String readLine () throws IOException { + StringBuilder sb = new StringBuilder(); + while (true) { + int b = read(); + // Line ends with \n; ignore \r + // I'm not sure this is the right thing with a raw \r (no \n following) + if (b < 0) + throw new IOException("Socket closed in readLine"); + if (b == '\n') + return sb.toString(); + else if (b != '\r') { + sb.append((char)b); + } + } + } + + public boolean ready () throws IOException { + return this.available() > 0; + } +} diff --git a/src/com/android/email/imap2/Parser.java b/src/com/android/email/imap2/Parser.java new file mode 100644 index 000000000..239298ede --- /dev/null +++ b/src/com/android/email/imap2/Parser.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2; + +import java.util.ArrayList; + +public class Parser { + String str; + int pos; + int len; + static final String white = "\r\n \t"; + + public Parser (String _str) { + str = _str; + pos = 0; + len = str.length(); + } + + public Parser (String _str, int start) { + str = _str; + pos = start; + len = str.length(); + } + + public void skipWhite () { + while ((pos < len) && white.indexOf(str.charAt(pos)) >= 0) + pos++; + } + + public String parseAtom () { + skipWhite(); + int start = pos; + while ((pos < len) && white.indexOf(str.charAt(pos)) < 0) + pos++; + if (pos > start) + return str.substring(start, pos); + return null; + } + + public char nextChar () { + if (pos >= len) + return 0; + else + return str.charAt(pos++); + } + + public char peekChar () { + if (pos >= len) + return 0; + else + return str.charAt(pos); + } + + public String parseString () { + return parseString(false); + } + + public String parseStringOrAtom () { + return parseString(true); + } + + public String parseString (boolean orAtom) { + skipWhite(); + char c = nextChar(); + if (c != '\"') { + if (c == '{') { + int cnt = parseInteger(); + c = nextChar(); + if (c != '}') + return null; + int start = pos + 2; + int end = start + cnt; + String s = str.substring(start, end); + pos = end; + return s; + } else if (orAtom) { + backChar(); + return parseAtom(); + } else if (c == 'n' || c == 'N') { + parseAtom(); + return null; + } else + return null; + } + int start = pos; + boolean quote = false; + while (true) { + c = nextChar(); + if (c == 0) + return null; + else if (quote) + quote = false; + else if (c == '\\') + quote = true; + else if (c == '\"') + break; + } + return str.substring(start, pos - 1); + } + + public void backChar () { + if (pos > 0) + pos--; + } + + public String parseListOrNil () { + String list = parseList(); + if (list == null) { + parseAtom(); + list = ""; + } + return list; + } + + public String parseList () { + skipWhite(); + if (nextChar() != '(') { + backChar(); + return null; + } + int start = pos; + int level = 0; + boolean quote = false; + boolean string = false; + while (true) { + char c = nextChar(); + if (c == 0) + return null; + else if (quote) + quote = false; + else if (c == '\\' && string) + quote = true; + else if (c == '\"') + string = !string; + else if (c == '(' && !string) + level++; + else if (c == ')' && !string) { + if (level-- == 0) + break; + } + } + return str.substring(start, pos - 1); + } + + public int parseInteger () { + skipWhite(); + int start = pos; + while (pos < len) { + char c = str.charAt(pos); + if (c >= '0' && c <= '9') + pos++; + else + break; + } + if (pos > start) { + // We know these are positive integers + int sum = 0; + for (int i = start; i < pos; i++) { + sum = (sum * 10) + (str.charAt(i) - '0'); + } + return sum; + } else { + return -1; + } + } + + public int[] gatherInts () { + int[] list = new int[128]; + int size = 128; + int offs = 0; + while (true) { + int i = parseInteger(); + if (i >= 0) { + if (offs == size) { + // Double the size of the array as necessary + size <<= 1; + int[] tmp = new int[size]; + System.arraycopy(list, 0, tmp, 0, offs); + list = tmp; + } + list[offs++] = i; + } + else + break; + } + int[] res = new int[offs]; + System.arraycopy(list, 0, res, 0, offs); + return res; + } + public Integer[] gatherIntegers () { + ArrayList<Integer> list = new ArrayList<Integer>(); + while (true) { + Integer i = parseInteger(); + if (i >= 0) { + list.add(i); + } + else + break; + } + return list.toArray(new Integer[list.size()]); + } +} diff --git a/src/com/android/email/imap2/QuotedPrintable.java b/src/com/android/email/imap2/QuotedPrintable.java new file mode 100644 index 000000000..54885e950 --- /dev/null +++ b/src/com/android/email/imap2/QuotedPrintable.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2; + +public class QuotedPrintable { + static public String toString (String str) { + int len = str.length(); + // Make sure we don't get an index out of bounds error with the = character + int max = len - 2; + StringBuilder sb = new StringBuilder(len); + try { + for (int i = 0; i < len; i++) { + char c = str.charAt(i); + if (c == '=') { + if (i < max) { + char n = str.charAt(++i); + if (n == '\r') { + n = str.charAt(++i); + if (n == '\n') + continue; + else + System.err.println("Not valid QP"); + } else { + // Must be less than 0x80, right? + int a; + if (n >= '0' && n <= '9') + a = (n - '0') << 4; + else + a = (10 + (n - 'A')) << 4; + + n = str.charAt(++i); + if (n >= '0' && n <= '9') + c = (char) (a + (n - '0')); + else + c = (char) (a + 10 + (n - 'A')); + } + } if (i + 1 == len) + continue; + } + + sb.append(c); + } + } catch (IndexOutOfBoundsException e) { + } + String ret = sb.toString(); + return ret; + } + + static public String encode (String str) { + int len = str.length(); + StringBuffer sb = new StringBuffer(len + len>>2); + int i = 0; + while (i < len) { + char c = str.charAt(i++); + if (c < 0x80) { + sb.append(c); + } else { + sb.append('&'); + sb.append('#'); + sb.append((int)c); + sb.append(';'); + } + } + return sb.toString(); + } + + static public int decode (byte[] bytes, int len) { + // Make sure we don't get an index out of bounds error with the = character + int max = len - 2; + int pos = 0; + try { + for (int i = 0; i < len; i++) { + char c = (char)bytes[i]; + if (c == '=') { + if (i < max) { + char n = (char)bytes[++i]; + if (n == '\r') { + n = (char)bytes[++i]; + if (n == '\n') + continue; + else + System.err.println("Not valid QP"); + } else { + // Must be less than 0x80, right? + int a; + if (n >= '0' && n <= '9') + a = (n - '0') << 4; + else + a = (10 + (n - 'A')) << 4; + + n = (char)bytes[++i]; + if (n >= '0' && n <= '9') + c = (char) (a + (n - '0')); + else + c = (char) (a + 10 + (n - 'A')); + } + } if (i + 1 > len) + continue; + } + + bytes[pos++] = (byte)c; + } + } catch (IndexOutOfBoundsException e) { + } + return pos; + } +} diff --git a/src/com/android/email/imap2/SearchRequest.java b/src/com/android/email/imap2/SearchRequest.java new file mode 100644 index 000000000..fc1e52bb7 --- /dev/null +++ b/src/com/android/email/imap2/SearchRequest.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2; + +import com.android.emailcommon.provider.EmailContent.Message; +import com.android.emailcommon.service.SearchParams; +import com.android.emailsync.Request; + +/** + * SearchRequest is the wrapper for server search requests. + */ +public class SearchRequest extends Request { + public final SearchParams mParams; + + public SearchRequest(SearchParams _params) { + super(Message.NO_MESSAGE); + mParams = _params; + } + + // SearchRequests are unique by their mailboxId + public boolean equals(Object o) { + if (!(o instanceof SearchRequest)) return false; + return ((SearchRequest)o).mParams.mMailboxId == mParams.mMailboxId; + } + + public int hashCode() { + return (int)mParams.mMailboxId; + } +} diff --git a/src/com/android/email/imap2/smtp/MailTransport.java b/src/com/android/email/imap2/smtp/MailTransport.java new file mode 100644 index 000000000..2dd80c40d --- /dev/null +++ b/src/com/android/email/imap2/smtp/MailTransport.java @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2.smtp; + +import com.android.emailcommon.Logging; +import com.android.emailcommon.mail.CertificateValidationException; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Transport; +import com.android.emailcommon.provider.HostAuth; +import com.android.emailcommon.utility.SSLUtils; + +import android.content.Context; +import android.util.Log; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.SocketException; + +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; + +/** + * This class implements the common aspects of "transport", one layer below the + * specific wire protocols such as POP3, IMAP, or SMTP. + */ +public class MailTransport implements Transport { + + // 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 final HostAuth mHostAuth; + private final Context mContext; + + private Socket mSocket; + private InputStream mIn; + private OutputStream mOut; + private boolean mLog = true; // STOPSHIP Don't ship with this set to true + + + public MailTransport(Context context, boolean log, HostAuth hostAuth) { + super(); + mContext = context; + mHostAuth = hostAuth; + } + + /** + * Returns a new transport, using the current transport as a model. The new transport is + * configured identically (as if {@link #setSecurity(int, boolean)}, {@link #setPort(int)} + * and {@link #setHost(String)} were invoked), but not opened or connected in any way. + */ + @Override + public Transport clone() { + return new MailTransport(mContext, mLog, mHostAuth); + } + + @Override + public String getHost() { + return mHostAuth.mAddress; + } + + @Override + public int getPort() { + return mHostAuth.mPort; + } + + @Override + public boolean canTrySslSecurity() { + return (mHostAuth.mFlags & HostAuth.FLAG_SSL) != 0; + } + + @Override + public boolean canTryTlsSecurity() { + return (mHostAuth.mFlags & HostAuth.FLAG_TLS) != 0; + } + + @Override + public boolean canTrustAllCertificates() { + return (mHostAuth.mFlags & HostAuth.FLAG_TRUST_ALL) != 0; + } + + /** + * Attempts to open a connection using the Uri supplied for connection parameters. Will attempt + * an SSL connection if indicated. + */ + @Override + public void open() throws MessagingException, CertificateValidationException { + if (mLog) { + Log.d(Logging.LOG_TAG, "*** SMTP open " + + getHost() + ":" + String.valueOf(getPort())); + } + + try { + SocketAddress socketAddress = new InetSocketAddress(getHost(), getPort()); + if (canTrySslSecurity()) { + mSocket = SSLUtils.getSSLSocketFactory( + mContext, mHostAuth, canTrustAllCertificates()).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, getHost()); + } + mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); + mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); + + } catch (SSLException e) { + if (mLog) { + Log.d(Logging.LOG_TAG, e.toString()); + } + throw new CertificateValidationException(e.getMessage(), e); + } catch (IOException ioe) { + if (mLog) { + Log.d(Logging.LOG_TAG, ioe.toString()); + } + throw new MessagingException(MessagingException.IOERROR, ioe.toString()); + } + } + + /** + * Attempts to reopen a TLS connection using the Uri supplied for connection parameters. + * + * NOTE: No explicit hostname verification is required here, because it's handled automatically + * by the call to createSocket(). + * + * TODO should we explicitly close the old socket? This seems funky to abandon it. + */ + @Override + public void reopenTls() throws MessagingException { + try { + mSocket = SSLUtils.getSSLSocketFactory(mContext, mHostAuth, canTrustAllCertificates()) + .createSocket(mSocket, getHost(), getPort(), true); + mSocket.setSoTimeout(SOCKET_READ_TIMEOUT); + mIn = new BufferedInputStream(mSocket.getInputStream(), 1024); + mOut = new BufferedOutputStream(mSocket.getOutputStream(), 512); + + } catch (SSLException e) { + if (mLog) { + Log.d(Logging.LOG_TAG, e.toString()); + } + throw new CertificateValidationException(e.getMessage(), e); + } catch (IOException ioe) { + if (mLog) { + Log.d(Logging.LOG_TAG, ioe.toString()); + } + throw new MessagingException(MessagingException.IOERROR, ioe.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. 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. + * + * <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 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); + } + } + + /** + * Set the socket timeout. + * @param timeoutMilliseconds the read timeout value if greater than {@code 0}, or + * {@code 0} for an infinite timeout. + */ + @Override + public void setSoTimeout(int timeoutMilliseconds) throws SocketException { + mSocket.setSoTimeout(timeoutMilliseconds); + } + + @Override + 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. + */ + @Override + 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; + } + + @Override + public InputStream getInputStream() { + return mIn; + } + + @Override + public OutputStream getOutputStream() { + return mOut; + } + + /** + * Writes a single line to the server using \r\n termination. + */ + @Override + public void writeLine(String s, String sensitiveReplacement) throws IOException { + if (mLog) { + if (sensitiveReplacement != null && !Logging.DEBUG_SENSITIVE) { + Log.d(Logging.LOG_TAG, ">>> " + sensitiveReplacement); + } else { + Log.d(Logging.LOG_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. + */ + @Override + public String readLine() 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 && mLog) { + Log.d(Logging.LOG_TAG, "End of stream reached while trying to read line."); + } + String ret = sb.toString(); + if (mLog) { + Log.d(Logging.LOG_TAG, "<<< " + ret); + } + return ret; + } + + @Override + public InetAddress getLocalAddress() { + if (isOpen()) { + return mSocket.getLocalAddress(); + } else { + return null; + } + } +} diff --git a/src/com/android/email/imap2/smtp/SmtpSender.java b/src/com/android/email/imap2/smtp/SmtpSender.java new file mode 100644 index 000000000..a90aea649 --- /dev/null +++ b/src/com/android/email/imap2/smtp/SmtpSender.java @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.email.imap2.smtp; + +import android.content.Context; +import android.util.Base64; +import android.util.Log; + +import com.android.emailcommon.Logging; +import com.android.emailcommon.internet.Rfc822Output; +import com.android.emailcommon.mail.Address; +import com.android.emailcommon.mail.AuthenticationFailedException; +import com.android.emailcommon.mail.CertificateValidationException; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Transport; +import com.android.emailcommon.provider.Account; +import com.android.emailcommon.provider.EmailContent.Message; +import com.android.emailcommon.provider.HostAuth; +import com.android.emailcommon.utility.EOLConvertingOutputStream; + +import java.io.IOException; +import java.net.Inet6Address; +import java.net.InetAddress; + +import javax.net.ssl.SSLException; + +/** + * This class handles all of the protocol-level aspects of sending messages via SMTP. + * TODO Remove dependence upon URI; there's no reason why we need it here + */ +public class SmtpSender { + + private static final int DEFAULT_SMTP_PORT = 587; + private static final int DEFAULT_SMTP_SSL_PORT = 465; + + private final Context mContext; + private Transport mTransport; + private String mUsername; + private String mPassword; + private boolean mLog; + + /** + * Creates a new sender for the given account. + */ + public SmtpSender(Context context, Account account, boolean log) { + mContext = context; + mLog = log; + HostAuth sendAuth = account.getOrCreateHostAuthSend(context); + mTransport = new MailTransport(context, mLog, sendAuth); + + String[] userInfoParts = sendAuth.getLogin(); + if (userInfoParts != null) { + mUsername = userInfoParts[0]; + mPassword = userInfoParts[1]; + } + } + + /** + * For testing only. Injects a different transport. The transport should already be set + * up and ready to use. Do not use for real code. + * @param testTransport The Transport to inject and use for all future communication. + */ + /* package */ void setTransport(Transport testTransport) { + mTransport = testTransport; + } + + public void open() throws MessagingException { + try { + mTransport.open(); + + // Eat the banner + executeSimpleCommand(null); + + String localHost = "localhost"; + // Try to get local address in the proper format. + InetAddress localAddress = mTransport.getLocalAddress(); + if (localAddress != null) { + // Address Literal formatted in accordance to RFC2821 Sec. 4.1.3 + StringBuilder sb = new StringBuilder(); + sb.append('['); + if (localAddress instanceof Inet6Address) { + sb.append("IPv6:"); + } + sb.append(localAddress.getHostAddress()); + sb.append(']'); + localHost = sb.toString(); + } + String result = executeSimpleCommand("EHLO " + localHost); + + /* + * TODO may need to add code to fall back to HELO I switched it from + * using HELO on non STARTTLS connections because of AOL's mail + * server. It won't let you use AUTH without EHLO. + * We should really be paying more attention to the capabilities + * and only attempting auth if it's available, and warning the user + * if not. + */ + if (mTransport.canTryTlsSecurity()) { + if (result.contains("STARTTLS")) { + executeSimpleCommand("STARTTLS"); + mTransport.reopenTls(); + /* + * Now resend the EHLO. Required by RFC2487 Sec. 5.2, and more specifically, + * Exim. + */ + result = executeSimpleCommand("EHLO " + localHost); + } else { + if (mLog) { + Log.d(Logging.LOG_TAG, "TLS not supported but required"); + } + throw new MessagingException(MessagingException.TLS_REQUIRED); + } + } + + /* + * result contains the results of the EHLO in concatenated form + */ + boolean authLoginSupported = result.matches(".*AUTH.*LOGIN.*$"); + boolean authPlainSupported = result.matches(".*AUTH.*PLAIN.*$"); + + if (mUsername != null && mUsername.length() > 0 && mPassword != null + && mPassword.length() > 0) { + if (authPlainSupported) { + saslAuthPlain(mUsername, mPassword); + } + else if (authLoginSupported) { + saslAuthLogin(mUsername, mPassword); + } + else { + if (mLog) { + Log.d(Logging.LOG_TAG, "No valid authentication mechanism found."); + } + throw new MessagingException(MessagingException.AUTH_REQUIRED); + } + } + } catch (SSLException e) { + if (mLog) { + Log.d(Logging.LOG_TAG, e.toString()); + } + throw new CertificateValidationException(e.getMessage(), e); + } catch (IOException ioe) { + if (mLog) { + Log.d(Logging.LOG_TAG, ioe.toString()); + } + throw new MessagingException(MessagingException.IOERROR, ioe.toString()); + } + } + + public void sendMessage(long messageId) throws MessagingException { + close(); + open(); + + Message message = Message.restoreMessageWithId(mContext, messageId); + if (message == null) { + throw new MessagingException("Trying to send non-existent message id=" + + Long.toString(messageId)); + } + Address from = Address.unpackFirst(message.mFrom); + Address[] to = Address.unpack(message.mTo); + Address[] cc = Address.unpack(message.mCc); + Address[] bcc = Address.unpack(message.mBcc); + + try { + executeSimpleCommand("MAIL FROM: " + "<" + from.getAddress() + ">"); + for (Address address : to) { + executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">"); + } + for (Address address : cc) { + executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">"); + } + for (Address address : bcc) { + executeSimpleCommand("RCPT TO: " + "<" + address.getAddress() + ">"); + } + executeSimpleCommand("DATA"); + // TODO byte stuffing + Rfc822Output.writeTo(mContext, messageId, + new EOLConvertingOutputStream(mTransport.getOutputStream()), + false /* do not use smart reply */, + false /* do not send BCC */); + executeSimpleCommand("\r\n."); + } catch (IOException ioe) { + throw new MessagingException("Unable to send message", ioe); + } + } + + /** + * Close the protocol (and the transport below it). + * + * MUST NOT return any exceptions. + */ + public void close() { + mTransport.close(); + } + + /** + * Send a single command and wait for a single response. Handles responses that continue + * onto multiple lines. Throws MessagingException if response code is 4xx or 5xx. All traffic + * is logged (if debug logging is enabled) so do not use this function for user ID or password. + * + * @param command The command string to send to the server. + * @return Returns the response string from the server. + */ + private String executeSimpleCommand(String command) throws IOException, MessagingException { + return executeSensitiveCommand(command, null); + } + + /** + * Send a single command and wait for a single response. Handles responses that continue + * onto multiple lines. Throws MessagingException if response code is 4xx or 5xx. + * + * @param command The command string to send to the server. + * @param sensitiveReplacement If the command includes sensitive data (e.g. authentication) + * please pass a replacement string here (for logging). + * @return Returns the response string from the server. + */ + private String executeSensitiveCommand(String command, String sensitiveReplacement) + throws IOException, MessagingException { + if (command != null) { + mTransport.writeLine(command, sensitiveReplacement); + } + + String line = mTransport.readLine(); + + String result = line; + + while (line.length() >= 4 && line.charAt(3) == '-') { + line = mTransport.readLine(); + result += line.substring(3); + } + + if (result.length() > 0) { + char c = result.charAt(0); + if ((c == '4') || (c == '5')) { + throw new MessagingException(result); + } + } + + return result; + } + + +// C: AUTH LOGIN +// S: 334 VXNlcm5hbWU6 +// C: d2VsZG9u +// S: 334 UGFzc3dvcmQ6 +// C: dzNsZDBu +// S: 235 2.0.0 OK Authenticated +// +// Lines 2-5 of the conversation contain base64-encoded information. The same conversation, with base64 strings decoded, reads: +// +// +// C: AUTH LOGIN +// S: 334 Username: +// C: weldon +// S: 334 Password: +// C: w3ld0n +// S: 235 2.0.0 OK Authenticated + + private void saslAuthLogin(String username, String password) throws MessagingException, + AuthenticationFailedException, IOException { + try { + executeSimpleCommand("AUTH LOGIN"); + executeSensitiveCommand( + Base64.encodeToString(username.getBytes(), Base64.NO_WRAP), + "/username redacted/"); + executeSensitiveCommand( + Base64.encodeToString(password.getBytes(), Base64.NO_WRAP), + "/password redacted/"); + } + catch (MessagingException me) { + if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') { + throw new AuthenticationFailedException(me.getMessage()); + } + throw me; + } + } + + private void saslAuthPlain(String username, String password) throws MessagingException, + AuthenticationFailedException, IOException { + byte[] data = ("\000" + username + "\000" + password).getBytes(); + data = Base64.encode(data, Base64.NO_WRAP); + try { + executeSensitiveCommand("AUTH PLAIN " + new String(data), "AUTH PLAIN /redacted/"); + } + catch (MessagingException me) { + if (me.getMessage().length() > 1 && me.getMessage().charAt(1) == '3') { + throw new AuthenticationFailedException(me.getMessage()); + } + throw me; + } + } +} diff --git a/src/com/android/email/service/EmailServiceUtils.java b/src/com/android/email/service/EmailServiceUtils.java index d44d9697d..ec0850db2 100644 --- a/src/com/android/email/service/EmailServiceUtils.java +++ b/src/com/android/email/service/EmailServiceUtils.java @@ -16,22 +16,35 @@ package com.android.email.service; +import android.accounts.AccountManager; +import android.accounts.AccountManagerFuture; +import android.accounts.AuthenticatorException; +import android.accounts.OperationCanceledException; import android.app.Service; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.content.res.TypedArray; import android.content.res.XmlResourceParser; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; import android.os.IBinder; import android.os.RemoteException; +import android.provider.CalendarContract; +import android.provider.ContactsContract; import android.util.Log; import com.android.email.R; import com.android.emailcommon.Api; import com.android.emailcommon.Logging; import com.android.emailcommon.provider.Account; +import com.android.emailcommon.provider.EmailContent; import com.android.emailcommon.provider.HostAuth; +import com.android.emailcommon.provider.EmailContent.AccountColumns; import com.android.emailcommon.service.EmailServiceProxy; import com.android.emailcommon.service.IEmailService; import com.android.emailcommon.service.IEmailServiceCallback; @@ -202,11 +215,110 @@ public class EmailServiceUtils { } } + private static void finishAccountManagerBlocker(AccountManagerFuture<?> future) { + try { + // Note: All of the potential errors are simply logged + // here, as there is nothing to actually do about them. + future.getResult(); + } catch (OperationCanceledException e) { + Log.w(Logging.LOG_TAG, e.toString()); + } catch (AuthenticatorException e) { + Log.w(Logging.LOG_TAG, e.toString()); + } catch (IOException e) { + Log.w(Logging.LOG_TAG, e.toString()); + } + } + + /** + * "Change" the account manager type of the account; this entails deleting the account + * and adding a new one. We can't call into AccountManager on the UI thread, but we might + * well be on it (currently no clean way of guaranteeing that we're not). + * + * @param context the caller's context + * @param amAccount the AccountManager account we're changing + * @param newType the new AccountManager type for this account + * @param newProtocol the protocol now being used + */ + private static void updateAccountManagerType(final Context context, + final android.accounts.Account amAccount, final String newType, + final String newProtocol) { + // STOPSHIP There must be a better way + Thread amThread = new Thread(new Runnable() { + @Override + public void run() { + updateAccountManagerTypeImpl(context, amAccount, newType, newProtocol); + }}); + amThread.start(); + } + + private static void updateAccountManagerTypeImpl(Context context, + android.accounts.Account amAccount, String newType, String newProtocol) { + ContentResolver resolver = context.getContentResolver(); + Cursor c = resolver.query(Account.CONTENT_URI, Account.CONTENT_PROJECTION, + AccountColumns.EMAIL_ADDRESS + "=?", new String[] { amAccount.name }, null); + // That's odd, isn't it? + if (c == null) return; + try { + if (c.moveToNext()) { + Log.w(Logging.LOG_TAG, "Converting " + amAccount.name + " to " + newProtocol); + // Get the EmailProvider Account/HostAuth + Account account = new Account(); + account.restore(c); + HostAuth hostAuth = + HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv); + if (hostAuth == null) return; + + ContentValues accountValues = new ContentValues(); + int oldFlags = account.mFlags; + + // Mark the provider account incomplete so it can't get reconciled away + account.mFlags |= Account.FLAGS_INCOMPLETE; + accountValues.put(AccountColumns.FLAGS, account.mFlags); + Uri accountUri = ContentUris.withAppendedId(Account.CONTENT_URI, account.mId); + resolver.update(accountUri, accountValues, null, null); + + // Change the HostAuth to reference the new protocol; this has to be done before + // trying to create the AccountManager account (below) + ContentValues hostValues = new ContentValues(); + hostValues.put(HostAuth.PROTOCOL, newProtocol); + resolver.update(ContentUris.withAppendedId(HostAuth.CONTENT_URI, hostAuth.mId), + hostValues, null, null); + + try { + // Get current settings for the existing AccountManager account + boolean email = ContentResolver.getSyncAutomatically(amAccount, + EmailContent.AUTHORITY); + boolean contacts = ContentResolver.getSyncAutomatically(amAccount, + ContactsContract.AUTHORITY); + boolean calendar = ContentResolver.getSyncAutomatically(amAccount, + CalendarContract.AUTHORITY); + + // Delete the AccountManager account + AccountManagerFuture<?> amFuture = AccountManager.get(context) + .removeAccount(amAccount, null, null); + finishAccountManagerBlocker(amFuture); + + // Set up a new AccountManager account with new type and old settings + amFuture = MailService.setupAccountManagerAccount(context, account, email, + calendar, contacts, null); + finishAccountManagerBlocker(amFuture); + Log.w(Logging.LOG_TAG, "Conversion complete!"); + } finally { + // Clear the incomplete flag on the provider account + accountValues.put(AccountColumns.FLAGS, oldFlags); + resolver.update(accountUri, accountValues, null, null); + } + } + } finally { + c.close(); + } + } + /** * Parse services.xml file to find our available email services */ @SuppressWarnings("unchecked") - private static void findServices(Context context) { + private static synchronized void findServices(Context context) { try { Resources res = context.getResources(); XmlResourceParser xml = res.getXml(R.xml.services); @@ -218,10 +330,27 @@ public class EmailServiceUtils { EmailServiceInfo info = new EmailServiceInfo(); TypedArray ta = res.obtainAttributes(xml, R.styleable.EmailServiceInfo); info.protocol = ta.getString(R.styleable.EmailServiceInfo_protocol); + info.accountType = ta.getString(R.styleable.EmailServiceInfo_accountType); + // Handle upgrade of one protocol to another (e.g. imap to imap2) + String newProtocol = ta.getString(R.styleable.EmailServiceInfo_replaceWith); + if (newProtocol != null) { + EmailServiceInfo newInfo = getServiceInfo(context, newProtocol); + if (newInfo == null) { + throw new IllegalStateException( + "Replacement service not found: " + newProtocol); + } + AccountManager am = AccountManager.get(context); + android.accounts.Account[] amAccounts = + am.getAccountsByType(info.accountType); + for (android.accounts.Account amAccount: amAccounts) { + updateAccountManagerType(context, amAccount, newInfo.accountType, + newProtocol); + } + continue; + } info.name = ta.getString(R.styleable.EmailServiceInfo_name); String klass = ta.getString(R.styleable.EmailServiceInfo_serviceClass); info.intentAction = ta.getString(R.styleable.EmailServiceInfo_intent); - info.accountType = ta.getString(R.styleable.EmailServiceInfo_accountType); info.defaultSsl = ta.getBoolean(R.styleable.EmailServiceInfo_defaultSsl, false); info.port = ta.getInteger(R.styleable.EmailServiceInfo_port, 0); info.portSsl = ta.getInteger(R.styleable.EmailServiceInfo_portSsl, 0); diff --git a/src/com/android/email/service/MailService.java b/src/com/android/email/service/MailService.java index 48cf9d2aa..cc99789aa 100644 --- a/src/com/android/email/service/MailService.java +++ b/src/com/android/email/service/MailService.java @@ -18,6 +18,7 @@ package com.android.email.service; import android.accounts.AccountManager; import android.accounts.AccountManagerCallback; +import android.accounts.AccountManagerFuture; import android.app.Service; import android.content.Context; import android.content.Intent; @@ -138,12 +139,14 @@ public class MailService extends Service { providerContext); } - public static void setupAccountManagerAccount(Context context, Account account, - boolean email, boolean calendar, boolean contacts, + public static AccountManagerFuture<Bundle> setupAccountManagerAccount(Context context, + Account account, boolean email, boolean calendar, boolean contacts, AccountManagerCallback<Bundle> callback) { Bundle options = new Bundle(); HostAuth hostAuthRecv = HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv); - if (hostAuthRecv == null) return; + if (hostAuthRecv == null) { + return null; + } // Set up username/password options.putString(EasAuthenticatorService.OPTIONS_USERNAME, account.mEmailAddress); options.putString(EasAuthenticatorService.OPTIONS_PASSWORD, hostAuthRecv.mPassword); @@ -151,7 +154,7 @@ public class MailService extends Service { options.putBoolean(EasAuthenticatorService.OPTIONS_CALENDAR_SYNC_ENABLED, calendar); options.putBoolean(EasAuthenticatorService.OPTIONS_EMAIL_SYNC_ENABLED, email); EmailServiceInfo info = EmailServiceUtils.getServiceInfo(context, hostAuthRecv.mProtocol); - AccountManager.get(context).addAccount(info.accountType, null, null, options, null, + return AccountManager.get(context).addAccount(info.accountType, null, null, options, null, callback, null); } } |
