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