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