EasOutboxSyncHandler.java revision d92a75c707461188e8743149476e8f49ef191b42
1package com.android.exchange.service;
2
3import android.content.ContentUris;
4import android.content.Context;
5import android.database.Cursor;
6import android.net.TrafficStats;
7import android.net.Uri;
8import android.text.format.DateUtils;
9import android.util.Log;
10
11import com.android.emailcommon.TrafficFlags;
12import com.android.emailcommon.internet.Rfc822Output;
13import com.android.emailcommon.provider.Account;
14import com.android.emailcommon.provider.EmailContent.Attachment;
15import com.android.emailcommon.provider.EmailContent.Body;
16import com.android.emailcommon.provider.EmailContent.BodyColumns;
17import com.android.emailcommon.provider.EmailContent.MailboxColumns;
18import com.android.emailcommon.provider.EmailContent.Message;
19import com.android.emailcommon.provider.EmailContent.MessageColumns;
20import com.android.emailcommon.provider.EmailContent.SyncColumns;
21import com.android.emailcommon.provider.Mailbox;
22import com.android.emailcommon.utility.Utility;
23import com.android.exchange.CommandStatusException.CommandStatus;
24import com.android.exchange.Eas;
25import com.android.exchange.EasResponse;
26import com.android.exchange.adapter.Parser;
27import com.android.exchange.adapter.Parser.EmptyStreamException;
28import com.android.exchange.adapter.Serializer;
29import com.android.exchange.adapter.Tags;
30import com.android.mail.utils.LogUtils;
31
32import org.apache.http.HttpEntity;
33import org.apache.http.HttpStatus;
34import org.apache.http.entity.InputStreamEntity;
35
36import java.io.ByteArrayOutputStream;
37import java.io.File;
38import java.io.FileInputStream;
39import java.io.FileNotFoundException;
40import java.io.FileOutputStream;
41import java.io.IOException;
42import java.io.InputStream;
43import java.io.OutputStream;
44import java.security.cert.CertificateException;
45import java.util.ArrayList;
46
47/**
48 * Performs an Exchange Outbox sync, i.e. sends all mail from the Outbox.
49 */
50public class EasOutboxSyncHandler extends EasServerConnection {
51    // Value for a message's server id when sending fails.
52    public static final int SEND_FAILED = 1;
53
54    // WHERE clause to query for unsent messages.
55    // TODO: Is the SEND_FAILED check actually what we want?
56    public static final String MAILBOX_KEY_AND_NOT_SEND_FAILED =
57            MessageColumns.MAILBOX_KEY + "=? and (" + SyncColumns.SERVER_ID + " is null or " +
58            SyncColumns.SERVER_ID + "!=" + SEND_FAILED + ')';
59
60    // This needs to be long enough to send the longest reasonable message, without being so long
61    // as to effectively "hang" sending of mail.  The standard 30 second timeout isn't long enough
62    // for pictures and the like.  For now, we'll use 15 minutes, in the knowledge that any socket
63    // failure would probably generate an Exception before timing out anyway
64    public static final long SEND_MAIL_TIMEOUT = 15 * DateUtils.MINUTE_IN_MILLIS;
65
66    private final Mailbox mMailbox;
67    private final File mCacheDir;
68
69    public EasOutboxSyncHandler(final Context context, final Account account,
70            final Mailbox mailbox) {
71        super(context, account);
72        mMailbox = mailbox;
73        mCacheDir = context.getCacheDir();
74    }
75
76    public void performSync() {
77        // Use SMTP flags for sending mail
78        TrafficStats.setThreadStatsTag(TrafficFlags.getSmtpFlags(mContext, mAccount));
79        // Get a cursor to Outbox messages
80        final Cursor c = mContext.getContentResolver().query(Message.CONTENT_URI,
81                Message.CONTENT_PROJECTION, MAILBOX_KEY_AND_NOT_SEND_FAILED,
82                new String[] {Long.toString(mMailbox.mId)}, null);
83        try {
84            // Loop through the messages, sending each one
85            while (c.moveToNext()) {
86                final Message message = new Message();
87                message.restore(c);
88                if (Utility.hasUnloadedAttachments(mContext, message.mId)) {
89                    // We'll just have to wait on this...
90                    continue;
91                }
92
93                // TODO: Fix -- how do we want to signal to UI that we started syncing?
94                // Note the entire callback mechanism here needs improving.
95                //sendMessageStatus(message.mId, null, EmailServiceStatus.IN_PROGRESS, 0);
96
97                if (!sendOneMessage(message,
98                        SmartSendInfo.getSmartSendInfo(mContext, mAccount, message))) {
99                    break;
100                }
101            }
102        } finally {
103            // TODO: Some sort of sendMessageStatus() is needed here.
104            c.close();
105        }
106    }
107
108    /**
109     * Information needed for SmartReply/SmartForward.
110     */
111    private static class SmartSendInfo {
112        public static final String[] BODY_SOURCE_PROJECTION =
113                new String[] {BodyColumns.SOURCE_MESSAGE_KEY};
114        public static final String WHERE_MESSAGE_KEY = Body.MESSAGE_KEY + "=?";
115
116        final String mItemId;
117        final String mCollectionId;
118        final boolean mIsReply;
119        final ArrayList<Attachment> mRequiredAtts;
120
121        private SmartSendInfo(final String itemId, final String collectionId, final boolean isReply,
122                final ArrayList<Attachment> requiredAtts) {
123            mItemId = itemId;
124            mCollectionId = collectionId;
125            mIsReply = isReply;
126            mRequiredAtts = requiredAtts;
127        }
128
129        public String generateSmartSendCmd() {
130            final StringBuilder sb = new StringBuilder();
131            sb.append(isForward() ? "SmartForward" : "SmartReply");
132            sb.append("&ItemId=");
133            sb.append(Uri.encode(mItemId, ":"));
134            sb.append("&CollectionId=");
135            sb.append(Uri.encode(mCollectionId, ":"));
136            return sb.toString();
137        }
138
139        public boolean isForward() {
140            return !mIsReply;
141        }
142
143        /**
144         * See if a given attachment is among an array of attachments; it is if the locations of
145         * both are the same (we're looking to see if they represent the same attachment on the
146         * server. Note that an attachment that isn't on the server (e.g. an outbound attachment
147         * picked from the  gallery) won't have a location, so the result will always be false.
148         *
149         * @param att the attachment to test
150         * @param atts the array of attachments to look in
151         * @return whether the test attachment is among the array of attachments
152         */
153        private static boolean amongAttachments(final Attachment att, final Attachment[] atts) {
154            final String location = att.mLocation;
155            if (location == null) return false;
156            for (final Attachment a: atts) {
157                if (location.equals(a.mLocation)) {
158                    return true;
159                }
160            }
161            return false;
162        }
163
164        /**
165         * If this message should use SmartReply or SmartForward, return an object with the data
166         * for the smart send.
167         *
168         * @param context the caller's context
169         * @param account the Account we're sending from
170         * @param message the Message being sent
171         * @return an object to support smart sending, or null if not applicable.
172         */
173        public static SmartSendInfo getSmartSendInfo(final Context context,
174                final Account account, final Message message) {
175            final int flags = message.mFlags;
176            // We only care about the original message if we include quoted text.
177            if ((flags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) != 0) {
178                return null;
179            }
180            final boolean reply = (flags & Message.FLAG_TYPE_REPLY) != 0;
181            final boolean forward = (flags & Message.FLAG_TYPE_FORWARD) != 0;
182            // We also only care for replies or forwards.
183            if (!reply && !forward) {
184                return null;
185            }
186            // Just a sanity check here, since we assume that reply and forward are mutually
187            // exclusive throughout this class.
188            if (reply && forward) {
189                return null;
190            }
191            // If we don't support SmartForward and it's a forward, then don't proceed.
192            if (forward && (account.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) == 0) {
193                return null;
194            }
195
196            // Note: itemId and collectionId are the terms used by EAS to refer to the serverId and
197            // mailboxId of a Message
198            String itemId = null;
199            String collectionId = null;
200
201            // First, we need to get the id of the reply/forward message
202            String[] cols = Utility.getRowColumns(context, Body.CONTENT_URI, BODY_SOURCE_PROJECTION,
203                    WHERE_MESSAGE_KEY, new String[] {Long.toString(message.mId)});
204            long refId = 0;
205            // TODO: We can probably just write a smarter query to do this all at once.
206            if (cols != null && cols[0] != null) {
207                refId = Long.parseLong(cols[0]);
208                // Then, we need the serverId and mailboxKey of the message
209                cols = Utility.getRowColumns(context, Message.CONTENT_URI, refId,
210                        SyncColumns.SERVER_ID, MessageColumns.MAILBOX_KEY,
211                        MessageColumns.PROTOCOL_SEARCH_INFO);
212                if (cols != null) {
213                    itemId = cols[0];
214                    final long boxId = Long.parseLong(cols[1]);
215                    // Then, we need the serverId of the mailbox
216                    cols = Utility.getRowColumns(context, Mailbox.CONTENT_URI, boxId,
217                            MailboxColumns.SERVER_ID);
218                    if (cols != null) {
219                        collectionId = cols[0];
220                    }
221                }
222            }
223            // We need either a longId or both itemId (serverId) and collectionId (mailboxId) to
224            // process a smart reply or a smart forward
225            if (itemId != null && collectionId != null) {
226                final ArrayList<Attachment> requiredAtts;
227                if (forward) {
228                    // See if we can really smart forward (all reference attachments must be sent)
229                    final Attachment[] outAtts =
230                            Attachment.restoreAttachmentsWithMessageId(context, message.mId);
231                    final Attachment[] refAtts =
232                            Attachment.restoreAttachmentsWithMessageId(context, refId);
233                    for (final Attachment refAtt: refAtts) {
234                        // If an original attachment isn't among what's going out, we can't be smart
235                        if (!amongAttachments(refAtt, outAtts)) {
236                            return null;
237                        }
238                    }
239                    requiredAtts = new ArrayList<Attachment>();
240                    for (final Attachment outAtt: outAtts) {
241                        // If an outgoing attachment isn't in original message, we must send it
242                        if (!amongAttachments(outAtt, refAtts)) {
243                            requiredAtts.add(outAtt);
244                        }
245                    }
246                } else {
247                    requiredAtts = null;
248                }
249                return new SmartSendInfo(itemId, collectionId, reply, requiredAtts);
250            }
251            return null;
252        }
253    }
254
255    /**
256     * Our own HttpEntity subclass that is able to insert opaque data (in this case the MIME
257     * representation of the message body as stored in a temporary file) into the serializer stream
258     */
259    private static class SendMailEntity extends InputStreamEntity {
260        private final FileInputStream mFileStream;
261        private final long mFileLength;
262        private final int mSendTag;
263        private final Message mMessage;
264        private final SmartSendInfo mSmartSendInfo;
265
266        public SendMailEntity(final FileInputStream instream, final long length, final int tag,
267                final Message message, final SmartSendInfo smartSendInfo) {
268            super(instream, length);
269            mFileStream = instream;
270            mFileLength = length;
271            mSendTag = tag;
272            mMessage = message;
273            mSmartSendInfo = smartSendInfo;
274        }
275
276        /**
277         * We always return -1 because we don't know the actual length of the POST data (this
278         * causes HttpClient to send the data in "chunked" mode)
279         */
280        @Override
281        public long getContentLength() {
282            final ByteArrayOutputStream baos = new ByteArrayOutputStream();
283            try {
284                // Calculate the overhead for the WBXML data
285                writeTo(baos, false);
286                // Return the actual size that will be sent
287                return baos.size() + mFileLength;
288            } catch (final IOException e) {
289                // Just return -1 (unknown)
290            } finally {
291                try {
292                    baos.close();
293                } catch (final IOException e) {
294                    // Ignore
295                }
296            }
297            return -1;
298        }
299
300        @Override
301        public void writeTo(final OutputStream outstream) throws IOException {
302            writeTo(outstream, true);
303        }
304
305        /**
306         * Write the message to the output stream
307         * @param outstream the output stream to write
308         * @param withData whether or not the actual data is to be written; true when sending
309         *   mail; false when calculating size only
310         * @throws IOException
311         */
312        public void writeTo(final OutputStream outstream, final boolean withData)
313                throws IOException {
314            // Not sure if this is possible; the check is taken from the superclass
315            if (outstream == null) {
316                throw new IllegalArgumentException("Output stream may not be null");
317            }
318
319            // We'll serialize directly into the output stream
320            final Serializer s = new Serializer(outstream);
321            // Send the appropriate initial tag
322            s.start(mSendTag);
323            // The Message-Id for this message (note that we cannot use the messageId stored in
324            // the message, as EAS 14 limits the length to 40 chars and we use 70+)
325            s.data(Tags.COMPOSE_CLIENT_ID, "SendMail-" + System.nanoTime());
326            // We always save sent mail
327            s.tag(Tags.COMPOSE_SAVE_IN_SENT_ITEMS);
328
329            // If we're using smart reply/forward, we need info about the original message
330            if (mSendTag != Tags.COMPOSE_SEND_MAIL) {
331                if (mSmartSendInfo != null) {
332                    s.start(Tags.COMPOSE_SOURCE);
333                    // For search results, use the long id (stored in mProtocolSearchInfo); else,
334                    // use folder id/item id combo
335                    if (mMessage.mProtocolSearchInfo != null) {
336                        s.data(Tags.COMPOSE_LONG_ID, mMessage.mProtocolSearchInfo);
337                    } else {
338                        s.data(Tags.COMPOSE_ITEM_ID, mSmartSendInfo.mItemId);
339                        s.data(Tags.COMPOSE_FOLDER_ID, mSmartSendInfo.mCollectionId);
340                    }
341                    s.end();  // Tags.COMPOSE_SOURCE
342                }
343            }
344
345            // Start the MIME tag; this is followed by "opaque" data (byte array)
346            s.start(Tags.COMPOSE_MIME);
347            // Send opaque data from the file stream
348            if (withData) {
349                s.opaque(mFileStream, (int)mFileLength);
350            } else {
351                s.opaqueWithoutData((int)mFileLength);
352            }
353            // And we're done
354            s.end().end().done();
355        }
356    }
357
358    private static class SendMailParser extends Parser {
359        private final int mStartTag;
360        private int mStatus;
361
362        public SendMailParser(final InputStream in, final int startTag) throws IOException {
363            super(in);
364            mStartTag = startTag;
365        }
366
367        public int getStatus() {
368            return mStatus;
369        }
370
371        /**
372         * The only useful info in the SendMail response is the status; we capture and save it
373         */
374        @Override
375        public boolean parse() throws IOException {
376            if (nextTag(START_DOCUMENT) != mStartTag) {
377                throw new IOException();
378            }
379            while (nextTag(START_DOCUMENT) != END_DOCUMENT) {
380                if (tag == Tags.COMPOSE_STATUS) {
381                    mStatus = getValueInt();
382                } else {
383                    skipTag();
384                }
385            }
386            return true;
387        }
388    }
389
390    /**
391     * Attempt to send one message.
392     * @param message The message to send.
393     * @param smartSendInfo The SmartSendInfo for this message, or null if we don't have or don't
394     *      want to use smart send.
395     * @return Whether or not sending this message succeeded.
396     * TODO: Improve how we handle the types of failures. I've left the old error codes in as TODOs
397     * for future reference.
398     */
399    private boolean sendOneMessage(final Message message, final SmartSendInfo smartSendInfo) {
400        final File tmpFile;
401        try {
402            tmpFile = File.createTempFile("eas_", "tmp", mCacheDir);
403        } catch (final IOException e) {
404            return false; // TODO: Handle SyncStatus.FAILURE_IO;
405        }
406
407        final EasResponse resp;
408        // Send behavior differs pre and post EAS14.
409        final boolean isEas14 = (Double.parseDouble(mAccount.mProtocolVersion) >=
410                Eas.SUPPORTED_PROTOCOL_EX2010_DOUBLE);
411        final int modeTag = getModeTag(isEas14, smartSendInfo);
412        try {
413            if (!writeMessageToTempFile(tmpFile, message, smartSendInfo)) {
414                return false; // TODO: Handle SyncStatus.FAILURE_IO;
415            }
416
417            final FileInputStream fileStream;
418            try {
419                fileStream = new FileInputStream(tmpFile);
420            } catch (final FileNotFoundException e) {
421                return false; // TODO: Handle SyncStatus.FAILURE_IO;
422            }
423            try {
424
425                final long fileLength = tmpFile.length();
426                final HttpEntity entity;
427                if (isEas14) {
428                    entity = new SendMailEntity(fileStream, fileLength, modeTag, message,
429                            smartSendInfo);
430                } else {
431                    entity = new InputStreamEntity(fileStream, fileLength);
432                }
433
434                // Create the appropriate command.
435                String cmd = "SendMail";
436                if (smartSendInfo != null) {
437                    // In EAS 14, we don't send itemId and collectionId in the command
438                    if (isEas14) {
439                        cmd = smartSendInfo.isForward() ? "SmartForward" : "SmartReply";
440                    } else {
441                        cmd = smartSendInfo.generateSmartSendCmd();
442                    }
443                }
444                // If we're not EAS 14, add our save-in-sent setting here
445                if (!isEas14) {
446                    cmd += "&SaveInSent=T";
447                }
448                // Finally, post SendMail to the server
449                try {
450                    resp = sendHttpClientPost(cmd, entity, SEND_MAIL_TIMEOUT);
451                } catch (final IOException e) {
452                    return false; // TODO: Handle SyncStatus.FAILURE_IO;
453                } catch (final CertificateException e) {
454                    return false;
455                }
456
457            } finally {
458                try {
459                    fileStream.close();
460                } catch (final IOException e) {
461                    // TODO: Should we do anything here, or is it ok to just proceed?
462                }
463            }
464        } finally {
465            if (tmpFile.exists()) {
466                tmpFile.delete();
467            }
468        }
469
470        try {
471            final int code = resp.getStatus();
472            if (code == HttpStatus.SC_OK) {
473                // HTTP OK before EAS 14 is a thumbs up; in EAS 14, we've got to parse
474                // the reply
475                if (isEas14) {
476                    try {
477                        // Try to parse the result
478                        final SendMailParser p = new SendMailParser(resp.getInputStream(), modeTag);
479                        // If we get here, the SendMail failed; go figure
480                        p.parse();
481                        // The parser holds the status
482                        final int status = p.getStatus();
483                        if (CommandStatus.isNeedsProvisioning(status)) {
484                            return false; // TODO: Handle SyncStatus.FAILURE_SECURITY;
485                        } else if (status == CommandStatus.ITEM_NOT_FOUND &&
486                                smartSendInfo != null) {
487                            // Let's retry without "smart" commands.
488                            return sendOneMessage(message, null);
489                        }
490                        // TODO: Set syncServerId = SEND_FAILED in DB?
491                        return false; // TODO: Handle SyncStatus.FAILURE_MESSAGE;
492                    } catch (final EmptyStreamException e) {
493                        // This is actually fine; an empty stream means SendMail succeeded
494                    } catch (final IOException e) {
495                        // Parsing failed in some other way.
496                        return false; // TODO: Handle SyncStatus.FAILURE_IO;
497                    }
498                }
499            } else if (code == HttpStatus.SC_INTERNAL_SERVER_ERROR && smartSendInfo != null) {
500                // Let's retry without "smart" commands.
501                return sendOneMessage(message, null);
502            } else {
503                if (resp.isAuthError()) {
504                    LogUtils.d(LogUtils.TAG, "Got auth error from server during outbox sync");
505                    return false; // TODO: Handle SyncStatus.FAILURE_LOGIN;
506                } else if (resp.isProvisionError()) {
507                    LogUtils.d(LogUtils.TAG, "Got provision error from server during outbox sync.");
508                    return false; // TODO: Handle SyncStatus.FAILURE_SECURITY;
509                } else {
510                    // TODO: Handle some other error
511                    LogUtils.d(LogUtils.TAG,
512                            "Got other HTTP error from server during outbox sync: %d", code);
513                    return false;
514                }
515            }
516        } finally {
517            resp.close();
518        }
519
520        // If we manage to get here, the message sent successfully. Hooray!
521        // Delete the sent message.
522        mContext.getContentResolver().delete(
523                ContentUris.withAppendedId(Message.CONTENT_URI, message.mId), null, null);
524        return true;
525    }
526
527    /**
528     * Writes message to the temp file.
529     * @param tmpFile The temp file to use.
530     * @param message The {@link Message} to write.
531     * @param smartSendInfo The {@link SmartSendInfo} for this message send attempt.
532     * @return Whether we could successfully write the file.
533     */
534    private boolean writeMessageToTempFile(final File tmpFile, final Message message,
535            final SmartSendInfo smartSendInfo) {
536        final FileOutputStream fileStream;
537        try {
538            fileStream = new FileOutputStream(tmpFile);
539        } catch (final FileNotFoundException e) {
540            Log.e(LogUtils.TAG, "Failed to create message file", e);
541            return false;
542        }
543        try {
544            final boolean smartSend = smartSendInfo != null;
545            final ArrayList<Attachment> attachments =
546                    smartSend ? smartSendInfo.mRequiredAtts : null;
547            Rfc822Output.writeTo(mContext, message, fileStream, smartSend, true, attachments);
548        } catch (final Exception e) {
549            Log.e(LogUtils.TAG, "Failed to write message file", e);
550            return false;
551        } finally {
552            try {
553                fileStream.close();
554            } catch (final IOException e) {
555                // should not happen
556                Log.e(LogUtils.TAG, "Failed to close file - should not happen", e);
557            }
558        }
559        return true;
560    }
561
562    private static int getModeTag(final boolean isEas14, final SmartSendInfo smartSendInfo) {
563        if (isEas14) {
564            if (smartSendInfo == null) {
565                return Tags.COMPOSE_SEND_MAIL;
566            } else if (smartSendInfo.isForward()) {
567                return Tags.COMPOSE_SMART_FORWARD;
568            } else {
569                return Tags.COMPOSE_SMART_REPLY;
570            }
571        }
572        return 0;
573    }
574}
575