summaryrefslogtreecommitdiffstats
path: root/src/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/com')
-rw-r--r--src/com/android/email/imap2/AttachmentLoader.java203
-rw-r--r--src/com/android/email/imap2/EmailSyncAdapterService.java122
-rw-r--r--src/com/android/email/imap2/Imap2SyncManager.java359
-rw-r--r--src/com/android/email/imap2/Imap2SyncService.java2486
-rw-r--r--src/com/android/email/imap2/ImapId.java196
-rw-r--r--src/com/android/email/imap2/ImapInputStream.java48
-rw-r--r--src/com/android/email/imap2/Parser.java216
-rw-r--r--src/com/android/email/imap2/QuotedPrintable.java121
-rw-r--r--src/com/android/email/imap2/SearchRequest.java43
-rw-r--r--src/com/android/email/imap2/smtp/MailTransport.java325
-rw-r--r--src/com/android/email/imap2/smtp/SmtpSender.java306
-rw-r--r--src/com/android/email/service/EmailServiceUtils.java133
-rw-r--r--src/com/android/email/service/MailService.java11
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);
}
}