Fix Exchange attachment loading.

Also, minor refactor with EasServerConnection ctor.

Note: this implementation has (at least) 2 flaws:
- It can run concurrently with sync, which is probably bad.
- It doesn't do any status updates to the UI.

There are bugs filed for these.

Change-Id: I6c41842afbed45af7747a460010e49fa3010e2a5
diff --git a/src/com/android/exchange/adapter/AttachmentLoader.java b/src/com/android/exchange/adapter/AttachmentLoader.java
index 2747644..2eeca81 100644
--- a/src/com/android/exchange/adapter/AttachmentLoader.java
+++ b/src/com/android/exchange/adapter/AttachmentLoader.java
@@ -45,8 +45,6 @@
  * Handle EAS attachment loading, regardless of protocol version
  */
 public class AttachmentLoader {
-    static private final int CHUNK_SIZE = 16*1024;
-
     private final EasSyncService mService;
     private final Context mContext;
     private final Attachment mAttachment;
@@ -82,55 +80,6 @@
         }
     }
 
-    /**
-     * Read the attachment data in chunks and write the data back out to our attachment file
-     * @param inputStream the InputStream we're reading the attachment from
-     * @param outputStream the OutputStream the attachment will be written to
-     * @param len the number of expected bytes we're going to read
-     * @throws IOException
-     */
-    public void readChunked(InputStream inputStream, OutputStream outputStream, int len)
-            throws IOException {
-        byte[] bytes = new byte[CHUNK_SIZE];
-        int length = len;
-        // Loop terminates 1) when EOF is reached or 2) IOException occurs
-        // One of these is guaranteed to occur
-        int totalRead = 0;
-        int lastCallbackPct = -1;
-        int lastCallbackTotalRead = 0;
-        mService.userLog("Expected attachment length: ", len);
-        while (true) {
-            int read = inputStream.read(bytes, 0, CHUNK_SIZE);
-            if (read < 0) {
-                // -1 means EOF
-                mService.userLog("Attachment load reached EOF, totalRead: ", totalRead);
-                break;
-            }
-
-            // Keep track of how much we've read for progress callback
-            totalRead += read;
-            // Write these bytes out
-            outputStream.write(bytes, 0, read);
-
-            // We can't report percentage if data is chunked; the length of incoming data is unknown
-            if (length > 0) {
-                int pct = (totalRead * 100) / length;
-                // 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;
-                }
-            }
-        }
-        if (totalRead > length) {
-            // Apparently, the length, as reported by EAS, isn't always accurate; let's log it
-            mService.userLog("Read more than expected: ", totalRead);
-        }
-    }
-
     @VisibleForTesting
     static String encodeForExchange2003(String str) {
         AttachmentNameEncoder enc = new AttachmentNameEncoder();
@@ -224,7 +173,7 @@
                         tmpFile = File.createTempFile("eas_", "tmp", mContext.getCacheDir());
                         os = new FileOutputStream(tmpFile);
                         if (eas14) {
-                            ItemOperationsParser p = new ItemOperationsParser(this, is, os,
+                            ItemOperationsParser p = new ItemOperationsParser(is, os,
                                     mAttachmentSize);
                             p.parse();
                             if (p.getStatusCode() == 1 /* Success */) {
@@ -236,7 +185,8 @@
                             if (len != 0) {
                                 // len > 0 means that Content-Length was set in the headers
                                 // len < 0 means "chunked" transfer-encoding
-                                readChunked(is, os, (len < 0) ? mAttachmentSize : len);
+                                ItemOperationsParser.readChunked(is, os,
+                                        (len < 0) ? mAttachmentSize : len);
                                 finishLoadAttachment(tmpFile);
                                 return;
                             }
diff --git a/src/com/android/exchange/adapter/ItemOperationsParser.java b/src/com/android/exchange/adapter/ItemOperationsParser.java
index 5f412ad..7cd4f36 100644
--- a/src/com/android/exchange/adapter/ItemOperationsParser.java
+++ b/src/com/android/exchange/adapter/ItemOperationsParser.java
@@ -23,15 +23,15 @@
  * Parse the result of an ItemOperations command; we use this to load attachments in EAS 14.0
  */
 public class ItemOperationsParser extends Parser {
-    private final AttachmentLoader mAttachmentLoader;
+    private static final int CHUNK_SIZE = 16*1024;
+
     private int mStatusCode = 0;
     private final OutputStream mAttachmentOutputStream;
-    private final int mAttachmentSize;
+    private final long mAttachmentSize;
 
-    public ItemOperationsParser(AttachmentLoader loader, InputStream in, OutputStream out, int size)
+    public ItemOperationsParser(final InputStream in, final OutputStream out, final long size)
             throws IOException {
         super(in);
-        mAttachmentLoader = loader;
         mAttachmentOutputStream = out;
         mAttachmentSize = size;
     }
@@ -46,7 +46,7 @@
                 // Wrap the input stream in our custom base64 input stream
                 Base64InputStream bis = new Base64InputStream(getInput());
                 // Read the attachment
-                mAttachmentLoader.readChunked(bis, mAttachmentOutputStream, mAttachmentSize);
+                readChunked(bis, mAttachmentOutputStream, mAttachmentSize);
             } else {
                 skipTag();
             }
@@ -91,4 +91,47 @@
         }
         return res;
     }
+
+    /**
+     * Read the attachment data in chunks and write the data back out to our attachment file
+     * @param inputStream the InputStream we're reading the attachment from
+     * @param outputStream the OutputStream the attachment will be written to
+     * @param length the number of expected bytes we're going to read
+     * @throws IOException
+     */
+    public static void readChunked(final InputStream inputStream, final OutputStream outputStream,
+            final long length) throws IOException {
+        final byte[] bytes = new byte[CHUNK_SIZE];
+        // Loop terminates 1) when EOF is reached or 2) IOException occurs
+        // One of these is guaranteed to occur
+        int totalRead = 0;
+        long lastCallbackPct = -1;
+        int lastCallbackTotalRead = 0;
+        while (true) {
+            final int read = inputStream.read(bytes, 0, CHUNK_SIZE);
+            if (read < 0) {
+                // -1 means EOF
+                break;
+            }
+
+            // Keep track of how much we've read for progress callback
+            totalRead += read;
+            // Write these bytes out
+            outputStream.write(bytes, 0, read);
+
+            // We can't report percentage if data is chunked; the length of incoming data is unknown
+            if (length > 0) {
+                final long pct = (totalRead * 100) / length;
+                // 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
+                    // TODO: Fix this.
+                    //doProgressCallback(pct);
+                    lastCallbackTotalRead = totalRead;
+                    lastCallbackPct = pct;
+                }
+            }
+        }
+    }
 }
diff --git a/src/com/android/exchange/service/EasAccountSyncHandler.java b/src/com/android/exchange/service/EasAccountSyncHandler.java
index 3718eb1..3bf0b16 100644
--- a/src/com/android/exchange/service/EasAccountSyncHandler.java
+++ b/src/com/android/exchange/service/EasAccountSyncHandler.java
@@ -3,7 +3,6 @@
 import android.content.Context;
 
 import com.android.emailcommon.provider.Account;
-import com.android.emailcommon.provider.HostAuth;
 
 
 /**
@@ -11,7 +10,7 @@
  */
 public class EasAccountSyncHandler extends EasAccountValidator {
     public EasAccountSyncHandler(final Context context, final Account account) {
-        super(context, account, HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv));
+        super(context, account);
     }
 
     public void performSync() {
diff --git a/src/com/android/exchange/service/EasAccountValidator.java b/src/com/android/exchange/service/EasAccountValidator.java
index 9be2c01..fa28bbe 100644
--- a/src/com/android/exchange/service/EasAccountValidator.java
+++ b/src/com/android/exchange/service/EasAccountValidator.java
@@ -75,12 +75,16 @@
         }
     }
 
-    public EasAccountValidator(final Context context, final Account account,
+    private EasAccountValidator(final Context context, final Account account,
             final HostAuth hostAuth) {
         super(context, account, hostAuth);
         mRedirectCount = 0;
     }
 
+    protected EasAccountValidator(final Context context, final Account account) {
+        this(context, account, HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv));
+    }
+
     public EasAccountValidator(final Context context, final HostAuth hostAuth) {
         this(context, new Account(), hostAuth);
         mAccount.mEmailAddress = mHostAuth.mLogin;
diff --git a/src/com/android/exchange/service/EasAttachmentLoader.java b/src/com/android/exchange/service/EasAttachmentLoader.java
new file mode 100644
index 0000000..66555e0
--- /dev/null
+++ b/src/com/android/exchange/service/EasAttachmentLoader.java
@@ -0,0 +1,233 @@
+package com.android.exchange.service;
+
+import android.content.Context;
+
+import com.android.emailcommon.provider.Account;
+import com.android.emailcommon.provider.EmailContent.Attachment;
+import com.android.emailcommon.utility.AttachmentUtilities;
+import com.android.exchange.Eas;
+import com.android.exchange.EasResponse;
+import com.android.exchange.adapter.ItemOperationsParser;
+import com.android.exchange.adapter.Serializer;
+import com.android.exchange.adapter.Tags;
+import com.android.exchange.utility.UriCodec;
+import com.android.mail.utils.LogUtils;
+
+import org.apache.http.HttpStatus;
+
+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;
+
+/**
+ * Loads attachments from the Exchange server.
+ * TODO: Add ability to call back to UI when this failed, and generally better handle error cases.
+ */
+public class EasAttachmentLoader extends EasServerConnection {
+    private static final String TAG = "EasAttachmentLoader";
+
+    private EasAttachmentLoader(final Context context, final Account account) {
+        super(context, account);
+    }
+
+    /**
+     * Load an attachment from the Exchange server, and write it to the content provider.
+     * @param context Our {@link Context}.
+     * @param attachmentId The local id of the attachment (i.e. its id in the database).
+     */
+    public static void loadAttachment(final Context context, final long attachmentId) {
+        // TODO: This function should do status callbacks when starting and ending the download.
+        // (Progress updates need to be handled in the chunk loader.)
+        final Attachment attachment = Attachment.restoreAttachmentWithId(context, attachmentId);
+        if (attachment == null) {
+            LogUtils.d(TAG, "Could not load attachment %d", attachmentId);
+        } else {
+            final Account account = Account.restoreAccountWithId(context, attachment.mAccountKey);
+            if (account == null) {
+                LogUtils.d(TAG, "Attachment %d has bad account key %d", attachment.mId,
+                        attachment.mAccountKey);
+                // TODO: Purge this attachment?
+            } else {
+                final EasAttachmentLoader loader = new EasAttachmentLoader(context, account);
+                loader.load(attachment);
+            }
+        }
+    }
+
+    /**
+     * Encoder for Exchange 2003 attachment names.  They come from the server partially encoded,
+     * but there are still possible characters that need to be encoded (Why, MSFT, why?)
+     */
+    private static class AttachmentNameEncoder extends UriCodec {
+        @Override
+        protected boolean isRetained(final char c) {
+            // These four characters are commonly received in EAS 2.5 attachment names and are
+            // valid (verified by testing); we won't encode them
+            return c == '_' || c == ':' || c == '/' || c == '.';
+        }
+    }
+
+    /**
+     * Finish encoding attachment names for Exchange 2003.
+     * @param str A partially encoded string.
+     * @return The fully encoded version of str.
+     */
+    private static String encodeForExchange2003(final String str) {
+        final AttachmentNameEncoder enc = new AttachmentNameEncoder();
+        final StringBuilder sb = new StringBuilder(str.length() + 16);
+        enc.appendPartiallyEncoded(sb, str);
+        return sb.toString();
+    }
+
+    /**
+     * Make the appropriate Exchange server request for getting the attachment.
+     * @param attachment The {@link Attachment} we wish to load.
+     * @return The {@link EasResponse} for the request, or null if we encountered an error.
+     */
+    private EasResponse performServerRequest(final Attachment attachment) {
+        try {
+            // The method of attachment loading is different in EAS 14.0 than in earlier versions
+            final String cmd;
+            final byte[] bytes;
+            if (getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE) {
+                final Serializer s = new Serializer();
+                s.start(Tags.ITEMS_ITEMS).start(Tags.ITEMS_FETCH);
+                s.data(Tags.ITEMS_STORE, "Mailbox");
+                s.data(Tags.BASE_FILE_REFERENCE, attachment.mLocation);
+                s.end().end().done(); // ITEMS_FETCH, ITEMS_ITEMS
+                cmd = "ItemOperations";
+                bytes = s.toByteArray();
+            } else {
+                final String location;
+                // For Exchange 2003 (EAS 2.5), we have to look for illegal chars in the file name
+                // that EAS sent to us!
+                if (getProtocolVersion() < Eas.SUPPORTED_PROTOCOL_EX2007_DOUBLE) {
+                    location = encodeForExchange2003(attachment.mLocation);
+                } else {
+                    location = attachment.mLocation;
+                }
+                cmd = "GetAttachment&AttachmentName=" + location;
+                bytes = null;
+            }
+            return sendHttpClientPost(cmd, bytes);
+        } catch (final IOException e) {
+            LogUtils.w(TAG, "IOException while loading attachment from server: %s", e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * Close, ignoring errors (as during cleanup)
+     * @param c a Closeable
+     */
+    private static void close(final Closeable c) {
+        try {
+            c.close();
+        } catch (IOException e) {
+            LogUtils.w(TAG, "IOException while cleaning up attachment: %s", e.getMessage());
+        }
+    }
+
+    /**
+     * Save away the contentUri for this Attachment and notify listeners
+     */
+    private boolean finishLoadAttachment(final Attachment attachment, final File file) {
+        final InputStream in;
+        try {
+            in = new FileInputStream(file);
+          } catch (final FileNotFoundException e) {
+            // Unlikely, as we just created it successfully, but log it.
+            LogUtils.e(TAG, "Could not open attachment file: %s", e.getMessage());
+            return false;
+        }
+        AttachmentUtilities.saveAttachment(mContext, in, attachment);
+        close(in);
+        return true;
+    }
+
+    /**
+     * Read the {@link EasResponse} and extract the attachment data, saving it to the provider.
+     * @param resp The (successful) {@link EasResponse} containing the attachment data.
+     * @param attachment The {@link Attachment} with the attachment metadata.
+     * @return Whether we successfully extracted the attachment data.
+     */
+    private boolean handleResponse(final EasResponse resp, final Attachment attachment) {
+        final File tmpFile;
+        try {
+            tmpFile = File.createTempFile("eas_", "tmp", mContext.getCacheDir());
+        } catch (final IOException e) {
+            LogUtils.w(TAG, "Could not open temp file: %s", e.getMessage());
+            return false;
+        }
+
+        try {
+            final OutputStream os;
+            try {
+                os = new FileOutputStream(tmpFile);
+            } catch (final FileNotFoundException e) {
+                LogUtils.w(TAG, "Temp file not found: %s", e.getMessage());
+                return false;
+            }
+            try {
+                final InputStream is = resp.getInputStream();
+                try {
+                    final boolean success;
+                    if (getProtocolVersion() >= Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE) {
+                        final ItemOperationsParser parser = new ItemOperationsParser(is, os,
+                                attachment.mSize);
+                        parser.parse();
+                        success = (parser.getStatusCode() == 1);
+                    } else {
+                        final int length = resp.getLength();
+                        if (length != 0) {
+                            // len > 0 means that Content-Length was set in the headers
+                            // len < 0 means "chunked" transfer-encoding
+                            ItemOperationsParser.readChunked(is, os,
+                                    (length < 0) ? attachment.mSize : length);
+                        }
+                        success = true;
+                    }
+                    if (success) {
+                        finishLoadAttachment(attachment, tmpFile);
+                    }
+                    return success;
+                } catch (final IOException e) {
+                    LogUtils.w(TAG, "Error reading attachment: %s", e.getMessage());
+                    return false;
+                } finally {
+                    close(is);
+                }
+            } finally {
+                close(os);
+            }
+        } finally {
+            tmpFile.delete();
+        }
+    }
+
+    /**
+     * Load the attachment from the server.
+     * @param attachment The {@link Attachment} we wish to load.
+     * @return Whether or not the load succeeded.
+     */
+    private boolean load(final Attachment attachment) {
+        final EasResponse resp = performServerRequest(attachment);
+        if (resp == null) {
+            return false;
+        }
+        try {
+            if (resp.getStatus() != HttpStatus.SC_OK || resp.isEmpty()) {
+                return false;
+            }
+            return handleResponse(resp, attachment);
+        } finally {
+            resp.close();
+        }
+    }
+
+}
diff --git a/src/com/android/exchange/service/EasOutboxSyncHandler.java b/src/com/android/exchange/service/EasOutboxSyncHandler.java
index a2f9c02..dc7e4ff 100644
--- a/src/com/android/exchange/service/EasOutboxSyncHandler.java
+++ b/src/com/android/exchange/service/EasOutboxSyncHandler.java
@@ -17,7 +17,6 @@
 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.utility.Utility;
 import com.android.exchange.CommandStatusException.CommandStatus;
@@ -66,7 +65,7 @@
 
     public EasOutboxSyncHandler(final Context context, final Account account,
             final Mailbox mailbox) {
-        super(context, account, HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv));
+        super(context, account);
         mMailbox = mailbox;
         mCacheDir = context.getCacheDir();
     }
diff --git a/src/com/android/exchange/service/EasPingSyncHandler.java b/src/com/android/exchange/service/EasPingSyncHandler.java
index d0d8d4d..0e2f387 100644
--- a/src/com/android/exchange/service/EasPingSyncHandler.java
+++ b/src/com/android/exchange/service/EasPingSyncHandler.java
@@ -12,7 +12,6 @@
 import com.android.emailcommon.provider.Account;
 import com.android.emailcommon.provider.EmailContent;
 import com.android.emailcommon.provider.EmailContent.MailboxColumns;
-import com.android.emailcommon.provider.HostAuth;
 import com.android.emailcommon.provider.Mailbox;
 import com.android.emailcommon.service.EmailServiceStatus;
 import com.android.exchange.Eas;
@@ -288,7 +287,7 @@
 
     public EasPingSyncHandler(final Context context, final Account account,
             final EmailSyncAdapterService.SyncHandlerSynchronizer syncHandlerMap) {
-        super(context, account, HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv));
+        super(context, account);
         mContentResolver = context.getContentResolver();
         mPingTask = new PingTask(syncHandlerMap);
         mPingTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
diff --git a/src/com/android/exchange/service/EasServerConnection.java b/src/com/android/exchange/service/EasServerConnection.java
index 66241d1..02ef3a8 100644
--- a/src/com/android/exchange/service/EasServerConnection.java
+++ b/src/com/android/exchange/service/EasServerConnection.java
@@ -39,6 +39,8 @@
 /**
  * Base class for communicating with an EAS server. Anything that needs to send messages to the
  * server can subclass this to get access to the {@link #sendHttpClientPost} family of functions.
+ * TODO: This class has a regrettable name. It's not a connection, but rather a task that happens
+ * to have (and use) a connection to the server.
  */
 public abstract class EasServerConnection {
     /** Logging tag. */
@@ -156,6 +158,10 @@
         mAccount = account;
     }
 
+    protected EasServerConnection(final Context context, final Account account) {
+        this(context, account, HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv));
+    }
+
     protected EmailClientConnectionManager getClientConnectionManager() {
         if (mConnectionManager == null) {
             mConnectionManager = sConnectionManagers.getConnectionManager(mContext, mHostAuth);
diff --git a/src/com/android/exchange/service/EasSyncHandler.java b/src/com/android/exchange/service/EasSyncHandler.java
index 149740f..d3aa30e 100644
--- a/src/com/android/exchange/service/EasSyncHandler.java
+++ b/src/com/android/exchange/service/EasSyncHandler.java
@@ -9,7 +9,6 @@
 
 import com.android.emailcommon.TrafficFlags;
 import com.android.emailcommon.provider.Account;
-import com.android.emailcommon.provider.HostAuth;
 import com.android.emailcommon.provider.Mailbox;
 import com.android.emailcommon.service.EmailServiceStatus;
 import com.android.exchange.CommandStatusException;
@@ -81,7 +80,7 @@
     protected EasSyncHandler(final Context context, final ContentResolver contentResolver,
             final Account account, final Mailbox mailbox, final Bundle syncExtras,
             final SyncResult syncResult) {
-        super(context, account, HostAuth.restoreHostAuthWithId(context, account.mHostAuthKeyRecv));
+        super(context, account);
         mContentResolver = contentResolver;
         mMailbox = mailbox;
         mSyncExtras = syncExtras;
diff --git a/src/com/android/exchange/service/EmailSyncAdapterService.java b/src/com/android/exchange/service/EmailSyncAdapterService.java
index 4caf7fa..4dcc51d 100644
--- a/src/com/android/exchange/service/EmailSyncAdapterService.java
+++ b/src/com/android/exchange/service/EmailSyncAdapterService.java
@@ -284,13 +284,10 @@
 
         @Override
         public void loadAttachment(final long attachmentId, final boolean background) {
-            LogUtils.d(TAG, "IEmailService.loadAttachment");
-            // TODO: Implement.
-            /*
-            Attachment att = Attachment.restoreAttachmentWithId(ExchangeService.this, attachmentId);
-            log("loadAttachment " + attachmentId + ": " + att.mFileName);
-            sendMessageRequest(new PartRequest(att, null, null));
-            */
+            LogUtils.d(TAG, "IEmailService.loadAttachment: %d", attachmentId);
+            // TODO: Make this go through the sync manager, so that it can't happen in parallel with
+            // a sync.
+            EasAttachmentLoader.loadAttachment(EmailSyncAdapterService.this, attachmentId);
         }
 
         @Override