WorkingMessage.java revision fd644551e8506266aad2b76463b51b44154ed62f
1 /*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.mms.data;
18
19import java.util.List;
20
21import com.android.mms.MmsConfig;
22import com.android.mms.ExceedMessageSizeException;
23import com.android.mms.ResolutionException;
24import com.android.mms.UnsupportContentTypeException;
25import com.android.mms.LogTag;
26import com.android.mmscommon.ContentType;
27import com.android.mmscommon.MmsException;
28import com.android.mmscommon.EncodedStringValue;
29import com.android.mmscommon.mms.pdu.PduBody;
30import com.android.mmscommon.mms.pdu.PduPersister;
31import com.android.mmscommon.mms.pdu.SendReq;
32import android.database.sqlite.SqliteWrapper;
33import com.android.mms.model.AudioModel;
34import com.android.mms.model.ImageModel;
35import com.android.mms.model.MediaModel;
36import com.android.mms.model.SlideModel;
37import com.android.mms.model.SlideshowModel;
38import com.android.mms.model.TextModel;
39import com.android.mms.model.VideoModel;
40import com.android.mms.transaction.MessageSender;
41import com.android.mms.transaction.MmsMessageSender;
42import com.android.mms.util.Recycler;
43import com.android.mms.util.DraftCache;
44import com.android.mms.transaction.SmsMessageSender;
45import com.android.mms.ui.ComposeMessageActivity;
46import com.android.mms.ui.MessageUtils;
47import com.android.mms.ui.SlideshowEditor;
48
49import android.content.ContentResolver;
50import android.content.ContentUris;
51import android.content.ContentValues;
52import android.content.Context;
53import android.database.Cursor;
54import android.net.Uri;
55import android.os.Bundle;
56import com.android.mmscommon.telephony.TelephonyProvider.Mms;
57import com.android.mmscommon.telephony.TelephonyProvider.Sms;
58import android.telephony.SmsMessage;
59import android.text.TextUtils;
60import android.util.Log;
61
62/**
63 * Contains all state related to a message being edited by the user.
64 */
65public class WorkingMessage {
66    private static final String TAG = "WorkingMessage";
67    private static final boolean DEBUG = false;
68
69    // Database access stuff
70    private final Context mContext;
71    private final ContentResolver mContentResolver;
72
73    // States that can require us to save or send a message as MMS.
74    private static final int RECIPIENTS_REQUIRE_MMS = (1 << 0);     // 1
75    private static final int HAS_SUBJECT = (1 << 1);                // 2
76    private static final int HAS_ATTACHMENT = (1 << 2);             // 4
77    private static final int LENGTH_REQUIRES_MMS = (1 << 3);        // 8
78    private static final int FORCE_MMS = (1 << 4);                  // 16
79
80    // A bitmap of the above indicating different properties of the message;
81    // any bit set will require the message to be sent via MMS.
82    private int mMmsState;
83
84    // Errors from setAttachment()
85    public static final int OK = 0;
86    public static final int UNKNOWN_ERROR = -1;
87    public static final int MESSAGE_SIZE_EXCEEDED = -2;
88    public static final int UNSUPPORTED_TYPE = -3;
89    public static final int IMAGE_TOO_LARGE = -4;
90
91    // Attachment types
92    public static final int TEXT = 0;
93    public static final int IMAGE = 1;
94    public static final int VIDEO = 2;
95    public static final int AUDIO = 3;
96    public static final int SLIDESHOW = 4;
97
98    // Current attachment type of the message; one of the above values.
99    private int mAttachmentType;
100
101    // Conversation this message is targeting.
102    private Conversation mConversation;
103
104    // Text of the message.
105    private CharSequence mText;
106    // Slideshow for this message, if applicable.  If it's a simple attachment,
107    // i.e. not SLIDESHOW, it will contain only one slide.
108    private SlideshowModel mSlideshow;
109    // Data URI of an MMS message if we have had to save it.
110    private Uri mMessageUri;
111    // MMS subject line for this message
112    private CharSequence mSubject;
113
114    // Set to true if this message has been discarded.
115    private boolean mDiscarded = false;
116
117    // Cached value of mms enabled flag
118    private static boolean sMmsEnabled = MmsConfig.getMmsEnabled();
119
120    // Our callback interface
121    private final MessageStatusListener mStatusListener;
122    private List<String> mWorkingRecipients;
123
124    // Message sizes in Outbox
125    private static final String[] MMS_OUTBOX_PROJECTION = {
126        Mms._ID,            // 0
127        Mms.MESSAGE_SIZE    // 1
128    };
129
130    private static final int MMS_MESSAGE_SIZE_INDEX  = 1;
131
132
133    /**
134     * Callback interface for communicating important state changes back to
135     * ComposeMessageActivity.
136     */
137    public interface MessageStatusListener {
138        /**
139         * Called when the protocol for sending the message changes from SMS
140         * to MMS, and vice versa.
141         *
142         * @param mms If true, it changed to MMS.  If false, to SMS.
143         */
144        void onProtocolChanged(boolean mms);
145
146        /**
147         * Called when an attachment on the message has changed.
148         */
149        void onAttachmentChanged();
150
151        /**
152         * Called just before the process of sending a message.
153         */
154        void onPreMessageSent();
155
156        /**
157         * Called once the process of sending a message, triggered by
158         * {@link send} has completed. This doesn't mean the send succeeded,
159         * just that it has been dispatched to the network.
160         */
161        void onMessageSent();
162
163        /**
164         * Called if there are too many unsent messages in the queue and we're not allowing
165         * any more Mms's to be sent.
166         */
167        void onMaxPendingMessagesReached();
168
169        /**
170         * Called if there's an attachment error while resizing the images just before sending.
171         */
172        void onAttachmentError(int error);
173    }
174
175    private WorkingMessage(ComposeMessageActivity activity) {
176        mContext = activity;
177        mContentResolver = mContext.getContentResolver();
178        mStatusListener = activity;
179        mAttachmentType = TEXT;
180        mText = "";
181    }
182
183    /**
184     * Creates a new working message.
185     */
186    public static WorkingMessage createEmpty(ComposeMessageActivity activity) {
187        // Make a new empty working message.
188        WorkingMessage msg = new WorkingMessage(activity);
189        return msg;
190    }
191
192    /**
193     * Create a new WorkingMessage from the specified data URI, which typically
194     * contains an MMS message.
195     */
196    public static WorkingMessage load(ComposeMessageActivity activity, Uri uri) {
197        // If the message is not already in the draft box, move it there.
198        if (!uri.toString().startsWith(Mms.Draft.CONTENT_URI.toString())) {
199            PduPersister persister = PduPersister.getPduPersister(activity);
200            if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
201                LogTag.debug("load: moving %s to drafts", uri);
202            }
203            try {
204                uri = persister.move(uri, Mms.Draft.CONTENT_URI);
205            } catch (MmsException e) {
206                LogTag.error("Can't move %s to drafts", uri);
207                return null;
208            }
209        }
210
211        WorkingMessage msg = new WorkingMessage(activity);
212        if (msg.loadFromUri(uri)) {
213            return msg;
214        }
215
216        return null;
217    }
218
219    private void correctAttachmentState() {
220        int slideCount = mSlideshow.size();
221
222        // If we get an empty slideshow, tear down all MMS
223        // state and discard the unnecessary message Uri.
224        if (slideCount == 0) {
225            mAttachmentType = TEXT;
226            mSlideshow = null;
227            asyncDelete(mMessageUri, null, null);
228            mMessageUri = null;
229        } else if (slideCount > 1) {
230            mAttachmentType = SLIDESHOW;
231        } else {
232            SlideModel slide = mSlideshow.get(0);
233            if (slide.hasImage()) {
234                mAttachmentType = IMAGE;
235            } else if (slide.hasVideo()) {
236                mAttachmentType = VIDEO;
237            } else if (slide.hasAudio()) {
238                mAttachmentType = AUDIO;
239            }
240        }
241
242        updateState(HAS_ATTACHMENT, hasAttachment(), false);
243    }
244
245    private boolean loadFromUri(Uri uri) {
246        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) LogTag.debug("loadFromUri %s", uri);
247        try {
248            mSlideshow = SlideshowModel.createFromMessageUri(mContext, uri);
249        } catch (MmsException e) {
250            LogTag.error("Couldn't load URI %s", uri);
251            return false;
252        }
253
254        mMessageUri = uri;
255
256        // Make sure all our state is as expected.
257        syncTextFromSlideshow();
258        correctAttachmentState();
259
260        return true;
261    }
262
263    /**
264     * Load the draft message for the specified conversation, or a new empty message if
265     * none exists.
266     */
267    public static WorkingMessage loadDraft(ComposeMessageActivity activity,
268                                           Conversation conv) {
269        WorkingMessage msg = new WorkingMessage(activity);
270        if (msg.loadFromConversation(conv)) {
271            return msg;
272        } else {
273            return createEmpty(activity);
274        }
275    }
276
277    private boolean loadFromConversation(Conversation conv) {
278        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) LogTag.debug("loadFromConversation %s", conv);
279
280        long threadId = conv.getThreadId();
281        if (threadId <= 0) {
282            return false;
283        }
284
285        // Look for an SMS draft first.
286        mText = readDraftSmsMessage(mContext, threadId, conv);
287        if (!TextUtils.isEmpty(mText)) {
288            return true;
289        }
290
291        // Then look for an MMS draft.
292        StringBuilder sb = new StringBuilder();
293        Uri uri = readDraftMmsMessage(mContext, threadId, sb);
294        if (uri != null) {
295            if (loadFromUri(uri)) {
296                // If there was an MMS message, readDraftMmsMessage
297                // will put the subject in our supplied StringBuilder.
298                if (sb.length() > 0) {
299                    setSubject(sb.toString(), false);
300                }
301                return true;
302            }
303        }
304
305        return false;
306    }
307
308    /**
309     * Sets the text of the message to the specified CharSequence.
310     */
311    public void setText(CharSequence s) {
312        mText = s;
313    }
314
315    /**
316     * Returns the current message text.
317     */
318    public CharSequence getText() {
319        return mText;
320    }
321
322    /**
323     * Returns true if the message has any text. A message with just whitespace is not considered
324     * to have text.
325     * @return
326     */
327    public boolean hasText() {
328        return mText != null && TextUtils.getTrimmedLength(mText) > 0;
329    }
330
331    /**
332     * Adds an attachment to the message, replacing an old one if it existed.
333     * @param type Type of this attachment, such as {@link IMAGE}
334     * @param dataUri Uri containing the attachment data (or null for {@link TEXT})
335     * @param append true if we should add the attachment to a new slide
336     * @return An error code such as {@link UNKNOWN_ERROR} or {@link OK} if successful
337     */
338    public int setAttachment(int type, Uri dataUri, boolean append) {
339        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
340            LogTag.debug("setAttachment type=%d uri %s", type, dataUri);
341        }
342        int result = OK;
343
344        // Make sure mSlideshow is set up and has a slide.
345        ensureSlideshow();
346
347        // Change the attachment and translate the various underlying
348        // exceptions into useful error codes.
349        try {
350            if (append) {
351                appendMedia(type, dataUri);
352            } else {
353                changeMedia(type, dataUri);
354            }
355        } catch (MmsException e) {
356            result = UNKNOWN_ERROR;
357        } catch (UnsupportContentTypeException e) {
358            result = UNSUPPORTED_TYPE;
359        } catch (ExceedMessageSizeException e) {
360            result = MESSAGE_SIZE_EXCEEDED;
361        } catch (ResolutionException e) {
362            result = IMAGE_TOO_LARGE;
363        }
364
365        // If we were successful, update mAttachmentType and notify
366        // the listener than there was a change.
367        if (result == OK) {
368            mAttachmentType = type;
369            mStatusListener.onAttachmentChanged();
370        } else if (append) {
371            // We added a new slide and what we attempted to insert on the slide failed.
372            // Delete that slide, otherwise we could end up with a bunch of blank slides.
373            SlideshowEditor slideShowEditor = new SlideshowEditor(mContext, mSlideshow);
374            slideShowEditor.removeSlide(mSlideshow.size() - 1);
375        }
376
377        // Set HAS_ATTACHMENT if we need it.
378        updateState(HAS_ATTACHMENT, hasAttachment(), true);
379
380        return result;
381    }
382
383    /**
384     * Returns true if this message contains anything worth saving.
385     */
386    public boolean isWorthSaving() {
387        // If it actually contains anything, it's of course not empty.
388        if (hasText() || hasSubject() || hasAttachment() || hasSlideshow()) {
389            return true;
390        }
391
392        // When saveAsMms() has been called, we set FORCE_MMS to represent
393        // sort of an "invisible attachment" so that the message isn't thrown
394        // away when we are shipping it off to other activities.
395        if ((mMmsState & FORCE_MMS) > 0) {
396            return true;
397        }
398
399        return false;
400    }
401
402    /**
403     * Makes sure mSlideshow is set up.
404     */
405    private void ensureSlideshow() {
406        if (mSlideshow != null) {
407            return;
408        }
409
410        SlideshowModel slideshow = SlideshowModel.createNew(mContext);
411        SlideModel slide = new SlideModel(slideshow);
412        slideshow.add(slide);
413
414        mSlideshow = slideshow;
415    }
416
417    /**
418     * Change the message's attachment to the data in the specified Uri.
419     * Used only for single-slide ("attachment mode") messages.
420     */
421    private void changeMedia(int type, Uri uri) throws MmsException {
422        SlideModel slide = mSlideshow.get(0);
423        MediaModel media;
424
425        // Remove any previous attachments.
426        slide.removeImage();
427        slide.removeVideo();
428        slide.removeAudio();
429
430        // If we're changing to text, just bail out.
431        if (type == TEXT) {
432            return;
433        }
434
435        // Make a correct MediaModel for the type of attachment.
436        if (type == IMAGE) {
437            media = new ImageModel(mContext, uri, mSlideshow.getLayout().getImageRegion());
438        } else if (type == VIDEO) {
439            media = new VideoModel(mContext, uri, mSlideshow.getLayout().getImageRegion());
440        } else if (type == AUDIO) {
441            media = new AudioModel(mContext, uri);
442        } else {
443            throw new IllegalArgumentException("changeMedia type=" + type + ", uri=" + uri);
444        }
445
446        // Add it to the slide.
447        slide.add(media);
448
449        // For video and audio, set the duration of the slide to
450        // that of the attachment.
451        if (type == VIDEO || type == AUDIO) {
452            slide.updateDuration(media.getDuration());
453        }
454    }
455
456    /**
457     * Add the message's attachment to the data in the specified Uri to a new slide.
458     */
459    private void appendMedia(int type, Uri uri) throws MmsException {
460
461        // If we're changing to text, just bail out.
462        if (type == TEXT) {
463            return;
464        }
465
466        // The first time this method is called, mSlideshow.size() is going to be
467        // one (a newly initialized slideshow has one empty slide). The first time we
468        // attach the picture/video to that first empty slide. From then on when this
469        // function is called, we've got to create a new slide and add the picture/video
470        // to that new slide.
471        boolean addNewSlide = true;
472        if (mSlideshow.size() == 1 && !mSlideshow.isSimple()) {
473            addNewSlide = false;
474        }
475        if (addNewSlide) {
476            SlideshowEditor slideShowEditor = new SlideshowEditor(mContext, mSlideshow);
477            if (!slideShowEditor.addNewSlide()) {
478                return;
479            }
480        }
481        // Make a correct MediaModel for the type of attachment.
482        MediaModel media;
483        SlideModel slide = mSlideshow.get(mSlideshow.size() - 1);
484        if (type == IMAGE) {
485            media = new ImageModel(mContext, uri, mSlideshow.getLayout().getImageRegion());
486        } else if (type == VIDEO) {
487            media = new VideoModel(mContext, uri, mSlideshow.getLayout().getImageRegion());
488        } else if (type == AUDIO) {
489            media = new AudioModel(mContext, uri);
490        } else {
491            throw new IllegalArgumentException("changeMedia type=" + type + ", uri=" + uri);
492        }
493
494        // Add it to the slide.
495        slide.add(media);
496
497        // For video and audio, set the duration of the slide to
498        // that of the attachment.
499        if (type == VIDEO || type == AUDIO) {
500            slide.updateDuration(media.getDuration());
501        }
502    }
503
504    /**
505     * Returns true if the message has an attachment (including slideshows).
506     */
507    public boolean hasAttachment() {
508        return (mAttachmentType > TEXT);
509    }
510
511    /**
512     * Returns the slideshow associated with this message.
513     */
514    public SlideshowModel getSlideshow() {
515        return mSlideshow;
516    }
517
518    /**
519     * Returns true if the message has a real slideshow, as opposed to just
520     * one image attachment, for example.
521     */
522    public boolean hasSlideshow() {
523        return (mAttachmentType == SLIDESHOW);
524    }
525
526    /**
527     * Sets the MMS subject of the message.  Passing null indicates that there
528     * is no subject.  Passing "" will result in an empty subject being added
529     * to the message, possibly triggering a conversion to MMS.  This extra
530     * bit of state is needed to support ComposeMessageActivity converting to
531     * MMS when the user adds a subject.  An empty subject will be removed
532     * before saving to disk or sending, however.
533     */
534    public void setSubject(CharSequence s, boolean notify) {
535        mSubject = s;
536        updateState(HAS_SUBJECT, (s != null), notify);
537    }
538
539    /**
540     * Returns the MMS subject of the message.
541     */
542    public CharSequence getSubject() {
543        return mSubject;
544    }
545
546    /**
547     * Returns true if this message has an MMS subject. A subject has to be more than just
548     * whitespace.
549     * @return
550     */
551    public boolean hasSubject() {
552        return mSubject != null && TextUtils.getTrimmedLength(mSubject) > 0;
553    }
554
555    /**
556     * Moves the message text into the slideshow.  Should be called any time
557     * the message is about to be sent or written to disk.
558     */
559    private void syncTextToSlideshow() {
560        if (mSlideshow == null || mSlideshow.size() != 1)
561            return;
562
563        SlideModel slide = mSlideshow.get(0);
564        TextModel text;
565        if (!slide.hasText()) {
566            // Add a TextModel to slide 0 if one doesn't already exist
567            text = new TextModel(mContext, ContentType.TEXT_PLAIN, "text_0.txt",
568                                           mSlideshow.getLayout().getTextRegion());
569            slide.add(text);
570        } else {
571            // Otherwise just reuse the existing one.
572            text = slide.getText();
573        }
574        text.setText(mText);
575    }
576
577    /**
578     * Sets the message text out of the slideshow.  Should be called any time
579     * a slideshow is loaded from disk.
580     */
581    private void syncTextFromSlideshow() {
582        // Don't sync text for real slideshows.
583        if (mSlideshow.size() != 1) {
584            return;
585        }
586
587        SlideModel slide = mSlideshow.get(0);
588        if (!slide.hasText()) {
589            return;
590        }
591
592        mText = slide.getText().getText();
593    }
594
595    /**
596     * Removes the subject if it is empty, possibly converting back to SMS.
597     */
598    private void removeSubjectIfEmpty(boolean notify) {
599        if (!hasSubject()) {
600            setSubject(null, notify);
601        }
602    }
603
604    /**
605     * Gets internal message state ready for storage.  Should be called any
606     * time the message is about to be sent or written to disk.
607     */
608    private void prepareForSave(boolean notify) {
609        // Make sure our working set of recipients is resolved
610        // to first-class Contact objects before we save.
611        syncWorkingRecipients();
612
613        if (requiresMms()) {
614            ensureSlideshow();
615            syncTextToSlideshow();
616            removeSubjectIfEmpty(notify);
617        }
618    }
619
620    /**
621     * Resolve the temporary working set of recipients to a ContactList.
622     */
623    public void syncWorkingRecipients() {
624        if (mWorkingRecipients != null) {
625            ContactList recipients = ContactList.getByNumbers(mWorkingRecipients, false);
626            mConversation.setRecipients(recipients);
627            mWorkingRecipients = null;
628        }
629    }
630
631
632    /**
633     * Force the message to be saved as MMS and return the Uri of the message.
634     * Typically used when handing a message off to another activity.
635     */
636    public Uri saveAsMms(boolean notify) {
637        if (DEBUG) LogTag.debug("save mConversation=%s", mConversation);
638
639        if (mDiscarded) {
640            throw new IllegalStateException("save() called after discard()");
641        }
642
643        // FORCE_MMS behaves as sort of an "invisible attachment", making
644        // the message seem non-empty (and thus not discarded).  This bit
645        // is sticky until the last other MMS bit is removed, at which
646        // point the message will fall back to SMS.
647        updateState(FORCE_MMS, true, notify);
648
649        // Collect our state to be written to disk.
650        prepareForSave(true /* notify */);
651
652        // Make sure we are saving to the correct thread ID.
653        mConversation.ensureThreadId();
654        mConversation.setDraftState(true);
655
656        PduPersister persister = PduPersister.getPduPersister(mContext);
657        SendReq sendReq = makeSendReq(mConversation, mSubject);
658
659        // If we don't already have a Uri lying around, make a new one.  If we do
660        // have one already, make sure it is synced to disk.
661        if (mMessageUri == null) {
662            mMessageUri = createDraftMmsMessage(persister, sendReq, mSlideshow);
663        } else {
664            updateDraftMmsMessage(mMessageUri, persister, mSlideshow, sendReq);
665        }
666
667        return mMessageUri;
668    }
669
670    /**
671     * Save this message as a draft in the conversation previously specified
672     * to {@link setConversation}.
673     */
674    public void saveDraft() {
675        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
676            LogTag.debug("saveDraft");
677        }
678
679        // If we have discarded the message, just bail out.
680        if (mDiscarded) {
681            return;
682        }
683
684        // Make sure setConversation was called.
685        if (mConversation == null) {
686            throw new IllegalStateException("saveDraft() called with no conversation");
687        }
688
689        // Get ready to write to disk. But don't notify message status when saving draft
690        prepareForSave(false /* notify */);
691
692        if (requiresMms()) {
693            asyncUpdateDraftMmsMessage(mConversation);
694        } else {
695            String content = mText.toString();
696
697            // bug 2169583: don't bother creating a thread id only to delete the thread
698            // because the content is empty. When we delete the thread in updateDraftSmsMessage,
699            // we didn't nullify conv.mThreadId, causing a temperary situation where conv
700            // is holding onto a thread id that isn't in the database. If a new message arrives
701            // and takes that thread id (because it's the next thread id to be assigned), the
702            // new message will be merged with the draft message thread, causing confusion!
703            if (!TextUtils.isEmpty(content)) {
704                asyncUpdateDraftSmsMessage(mConversation, content);
705            }
706        }
707
708        // Update state of the draft cache.
709        mConversation.setDraftState(true);
710    }
711
712    public void discard() {
713        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
714            LogTag.debug("discard");
715        }
716
717        // Technically, we could probably just bail out here.  But discard() is
718        // really meant to be called if you never want to use the message again,
719        // so keep this assert in as a debugging aid.
720        if (mDiscarded == true) {
721            throw new IllegalStateException("discard() called twice");
722        }
723
724        // Mark this message as discarded in order to make saveDraft() no-op.
725        mDiscarded = true;
726
727        // Delete our MMS message, if there is one.
728        if (mMessageUri != null) {
729            asyncDelete(mMessageUri, null, null);
730        }
731
732        // Delete any draft messages associated with this conversation.
733        asyncDeleteDraftSmsMessage(mConversation);
734
735        // Update state of the draft cache.
736        mConversation.setDraftState(false);
737    }
738
739    public void unDiscard() {
740        if (DEBUG) LogTag.debug("unDiscard");
741
742        mDiscarded = false;
743    }
744
745    /**
746     * Returns true if discard() has been called on this message.
747     */
748    public boolean isDiscarded() {
749        return mDiscarded;
750    }
751
752    /**
753     * To be called from our Activity's onSaveInstanceState() to give us a chance
754     * to stow our state away for later retrieval.
755     *
756     * @param bundle The Bundle passed in to onSaveInstanceState
757     */
758    public void writeStateToBundle(Bundle bundle) {
759        if (hasSubject()) {
760            bundle.putString("subject", mSubject.toString());
761        }
762
763        if (mMessageUri != null) {
764            bundle.putParcelable("msg_uri", mMessageUri);
765        } else if (hasText()) {
766            bundle.putString("sms_body", mText.toString());
767        }
768    }
769
770    /**
771     * To be called from our Activity's onCreate() if the activity manager
772     * has given it a Bundle to reinflate
773     * @param bundle The Bundle passed in to onCreate
774     */
775    public void readStateFromBundle(Bundle bundle) {
776        if (bundle == null) {
777            return;
778        }
779
780        String subject = bundle.getString("subject");
781        setSubject(subject, false);
782
783        Uri uri = (Uri)bundle.getParcelable("msg_uri");
784        if (uri != null) {
785            loadFromUri(uri);
786            return;
787        } else {
788            String body = bundle.getString("sms_body");
789            mText = body;
790        }
791    }
792
793    /**
794     * Update the temporary list of recipients, used when setting up a
795     * new conversation.  Will be converted to a ContactList on any
796     * save event (send, save draft, etc.)
797     */
798    public void setWorkingRecipients(List<String> numbers) {
799        mWorkingRecipients = numbers;
800    }
801
802    /**
803     * Set the conversation associated with this message.
804     */
805    public void setConversation(Conversation conv) {
806        if (DEBUG) LogTag.debug("setConversation %s -> %s", mConversation, conv);
807
808        mConversation = conv;
809
810        // Convert to MMS if there are any email addresses in the recipient list.
811        setHasEmail(conv.getRecipients().containsEmail(), false);
812    }
813
814    /**
815     * Hint whether or not this message will be delivered to an
816     * an email address.
817     */
818    public void setHasEmail(boolean hasEmail, boolean notify) {
819        if (MmsConfig.getEmailGateway() != null) {
820            updateState(RECIPIENTS_REQUIRE_MMS, false, notify);
821        } else {
822            updateState(RECIPIENTS_REQUIRE_MMS, hasEmail, notify);
823        }
824    }
825
826    /**
827     * Returns true if this message would require MMS to send.
828     */
829    public boolean requiresMms() {
830        return (mMmsState > 0);
831    }
832
833    /**
834     * Set whether or not we want to send this message via MMS in order to
835     * avoid sending an excessive number of concatenated SMS messages.
836     */
837    public void setLengthRequiresMms(boolean mmsRequired) {
838        updateState(LENGTH_REQUIRES_MMS, mmsRequired, true);
839    }
840
841    private static String stateString(int state) {
842        if (state == 0)
843            return "<none>";
844
845        StringBuilder sb = new StringBuilder();
846        if ((state & RECIPIENTS_REQUIRE_MMS) > 0)
847            sb.append("RECIPIENTS_REQUIRE_MMS | ");
848        if ((state & HAS_SUBJECT) > 0)
849            sb.append("HAS_SUBJECT | ");
850        if ((state & HAS_ATTACHMENT) > 0)
851            sb.append("HAS_ATTACHMENT | ");
852        if ((state & LENGTH_REQUIRES_MMS) > 0)
853            sb.append("LENGTH_REQUIRES_MMS | ");
854        if ((state & FORCE_MMS) > 0)
855            sb.append("FORCE_MMS | ");
856
857        sb.delete(sb.length() - 3, sb.length());
858        return sb.toString();
859    }
860
861    /**
862     * Sets the current state of our various "MMS required" bits.
863     *
864     * @param state The bit to change, such as {@link HAS_ATTACHMENT}
865     * @param on If true, set it; if false, clear it
866     * @param notify Whether or not to notify the user
867     */
868    private void updateState(int state, boolean on, boolean notify) {
869        if (!sMmsEnabled) {
870            // If Mms isn't enabled, the rest of the Messaging UI should not be using any
871            // feature that would cause us to to turn on any Mms flag and show the
872            // "Converting to multimedia..." message.
873            return;
874        }
875        int oldState = mMmsState;
876        if (on) {
877            mMmsState |= state;
878        } else {
879            mMmsState &= ~state;
880        }
881
882        // If we are clearing the last bit that is not FORCE_MMS,
883        // expire the FORCE_MMS bit.
884        if (mMmsState == FORCE_MMS && ((oldState & ~FORCE_MMS) > 0)) {
885            mMmsState = 0;
886        }
887
888        // Notify the listener if we are moving from SMS to MMS
889        // or vice versa.
890        if (notify) {
891            if (oldState == 0 && mMmsState != 0) {
892                mStatusListener.onProtocolChanged(true);
893            } else if (oldState != 0 && mMmsState == 0) {
894                mStatusListener.onProtocolChanged(false);
895            }
896        }
897
898        if (oldState != mMmsState) {
899            if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) LogTag.debug("updateState: %s%s = %s",
900                    on ? "+" : "-",
901                    stateString(state), stateString(mMmsState));
902        }
903    }
904
905    /**
906     * Send this message over the network.  Will call back with onMessageSent() once
907     * it has been dispatched to the telephony stack.  This WorkingMessage object is
908     * no longer useful after this method has been called.
909     */
910    public void send() {
911        if (Log.isLoggable(LogTag.TRANSACTION, Log.VERBOSE)) {
912            LogTag.debug("send");
913        }
914
915        // Get ready to write to disk.
916        prepareForSave(true /* notify */);
917
918        // We need the recipient list for both SMS and MMS.
919        final Conversation conv = mConversation;
920        String msgTxt = mText.toString();
921
922        if (requiresMms() || addressContainsEmailToMms(conv, msgTxt)) {
923            // Make local copies of the bits we need for sending a message,
924            // because we will be doing it off of the main thread, which will
925            // immediately continue on to resetting some of this state.
926            final Uri mmsUri = mMessageUri;
927            final PduPersister persister = PduPersister.getPduPersister(mContext);
928
929            final SlideshowModel slideshow = mSlideshow;
930            final SendReq sendReq = makeSendReq(conv, mSubject);
931
932            // Do the dirty work of sending the message off of the main UI thread.
933            new Thread(new Runnable() {
934                public void run() {
935                    // Make sure the text in slide 0 is no longer holding onto a reference to
936                    // the text in the message text box.
937                    slideshow.prepareForSend();
938                    sendMmsWorker(conv, mmsUri, persister, slideshow, sendReq);
939                }
940            }).start();
941        } else {
942            // Same rules apply as above.
943            final String msgText = mText.toString();
944            new Thread(new Runnable() {
945                public void run() {
946                    sendSmsWorker(conv, msgText);
947                }
948            }).start();
949        }
950
951        // update the Recipient cache with the new to address, if it's different
952        RecipientIdCache.updateNumbers(conv.getThreadId(), conv.getRecipients());
953
954        // Mark the message as discarded because it is "off the market" after being sent.
955        mDiscarded = true;
956    }
957
958    private boolean addressContainsEmailToMms(Conversation conv, String text) {
959        if (MmsConfig.getEmailGateway() != null) {
960            String[] dests = conv.getRecipients().getNumbers();
961            int length = dests.length;
962            for (int i = 0; i < length; i++) {
963                if (Mms.isEmailAddress(dests[i]) || MessageUtils.isAlias(dests[i])) {
964                    String mtext = dests[i] + " " + text;
965                    int[] params = SmsMessage.calculateLength(mtext, false);
966                    if (params[0] > 1) {
967                        updateState(RECIPIENTS_REQUIRE_MMS, true, true);
968                        ensureSlideshow();
969                        syncTextToSlideshow();
970                        return true;
971                    }
972                }
973            }
974        }
975        return false;
976    }
977
978    // Message sending stuff
979
980    private void sendSmsWorker(Conversation conv, String msgText) {
981        mStatusListener.onPreMessageSent();
982        // Make sure we are still using the correct thread ID for our
983        // recipient set.
984        long threadId = conv.ensureThreadId();
985        String[] dests = conv.getRecipients().getNumbers();
986
987        MessageSender sender = new SmsMessageSender(mContext, dests, msgText, threadId);
988        try {
989            sender.sendMessage(threadId);
990
991            // Make sure this thread isn't over the limits in message count
992            Recycler.getSmsRecycler().deleteOldMessagesByThreadId(mContext, threadId);
993        } catch (Exception e) {
994            Log.e(TAG, "Failed to send SMS message, threadId=" + threadId, e);
995        }
996
997        mStatusListener.onMessageSent();
998    }
999
1000    private void sendMmsWorker(Conversation conv, Uri mmsUri, PduPersister persister,
1001                               SlideshowModel slideshow, SendReq sendReq) {
1002        // First make sure we don't have too many outstanding unsent message.
1003        Cursor cursor = null;
1004        try {
1005            cursor = SqliteWrapper.query(mContext, mContentResolver,
1006                    Mms.Outbox.CONTENT_URI, MMS_OUTBOX_PROJECTION, null, null, null);
1007            if (cursor != null) {
1008                long maxMessageSize = MmsConfig.getMaxSizeScaleForPendingMmsAllowed() *
1009                    MmsConfig.getMaxMessageSize();
1010                long totalPendingSize = 0;
1011                while (cursor.moveToNext()) {
1012                    totalPendingSize += cursor.getLong(MMS_MESSAGE_SIZE_INDEX);
1013                }
1014                if (totalPendingSize >= maxMessageSize) {
1015                    unDiscard();    // it wasn't successfully sent. Allow it to be saved as a draft.
1016                    mStatusListener.onMaxPendingMessagesReached();
1017                    return;
1018                }
1019            }
1020        } finally {
1021            if (cursor != null) {
1022                cursor.close();
1023            }
1024        }
1025        mStatusListener.onPreMessageSent();
1026
1027        // Make sure we are still using the correct thread ID for our
1028        // recipient set.
1029        long threadId = conv.ensureThreadId();
1030
1031        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
1032            LogTag.debug("sendMmsWorker: update draft MMS message " + mmsUri);
1033        }
1034
1035        if (mmsUri == null) {
1036            // Create a new MMS message if one hasn't been made yet.
1037            mmsUri = createDraftMmsMessage(persister, sendReq, slideshow);
1038        } else {
1039            // Otherwise, sync the MMS message in progress to disk.
1040            updateDraftMmsMessage(mmsUri, persister, slideshow, sendReq);
1041        }
1042
1043        // Be paranoid and clean any draft SMS up.
1044        deleteDraftSmsMessage(threadId);
1045
1046        // Resize all the resizeable attachments (e.g. pictures) to fit
1047        // in the remaining space in the slideshow.
1048        int error = 0;
1049        try {
1050            slideshow.finalResize(mmsUri);
1051        } catch (ExceedMessageSizeException e1) {
1052            error = MESSAGE_SIZE_EXCEEDED;
1053        } catch (MmsException e1) {
1054            error = UNKNOWN_ERROR;
1055        }
1056        if (error != 0) {
1057            mStatusListener.onAttachmentError(error);
1058            return;
1059        }
1060
1061        MessageSender sender = new MmsMessageSender(mContext, mmsUri,
1062                slideshow.getCurrentMessageSize());
1063        try {
1064            if (!sender.sendMessage(threadId)) {
1065                // The message was sent through SMS protocol, we should
1066                // delete the copy which was previously saved in MMS drafts.
1067                SqliteWrapper.delete(mContext, mContentResolver, mmsUri, null, null);
1068            }
1069
1070            // Make sure this thread isn't over the limits in message count
1071            Recycler.getMmsRecycler().deleteOldMessagesByThreadId(mContext, threadId);
1072        } catch (Exception e) {
1073            Log.e(TAG, "Failed to send message: " + mmsUri + ", threadId=" + threadId, e);
1074        }
1075
1076        mStatusListener.onMessageSent();
1077    }
1078
1079    // Draft message stuff
1080
1081    private static final String[] MMS_DRAFT_PROJECTION = {
1082        Mms._ID,                // 0
1083        Mms.SUBJECT,            // 1
1084        Mms.SUBJECT_CHARSET     // 2
1085    };
1086
1087    private static final int MMS_ID_INDEX         = 0;
1088    private static final int MMS_SUBJECT_INDEX    = 1;
1089    private static final int MMS_SUBJECT_CS_INDEX = 2;
1090
1091    private static Uri readDraftMmsMessage(Context context, long threadId, StringBuilder sb) {
1092        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
1093            LogTag.debug("readDraftMmsMessage tid=%d", threadId);
1094        }
1095        Cursor cursor;
1096        ContentResolver cr = context.getContentResolver();
1097
1098        final String selection = Mms.THREAD_ID + " = " + threadId;
1099        cursor = SqliteWrapper.query(context, cr,
1100                Mms.Draft.CONTENT_URI, MMS_DRAFT_PROJECTION,
1101                selection, null, null);
1102
1103        Uri uri;
1104        try {
1105            if (cursor.moveToFirst()) {
1106                uri = ContentUris.withAppendedId(Mms.Draft.CONTENT_URI,
1107                        cursor.getLong(MMS_ID_INDEX));
1108                String subject = MessageUtils.extractEncStrFromCursor(cursor, MMS_SUBJECT_INDEX,
1109                        MMS_SUBJECT_CS_INDEX);
1110                if (subject != null) {
1111                    sb.append(subject);
1112                }
1113                return uri;
1114            }
1115        } finally {
1116            cursor.close();
1117        }
1118
1119        return null;
1120    }
1121
1122    /**
1123     * makeSendReq should always return a non-null SendReq, whether the dest addresses are
1124     * valid or not.
1125     */
1126    private static SendReq makeSendReq(Conversation conv, CharSequence subject) {
1127        String[] dests = conv.getRecipients().getNumbers(true /* scrub for MMS address */);
1128
1129        SendReq req = new SendReq();
1130        EncodedStringValue[] encodedNumbers = EncodedStringValue.encodeStrings(dests);
1131        if (encodedNumbers != null) {
1132            req.setTo(encodedNumbers);
1133        }
1134
1135        if (!TextUtils.isEmpty(subject)) {
1136            req.setSubject(new EncodedStringValue(subject.toString()));
1137        }
1138
1139        req.setDate(System.currentTimeMillis() / 1000L);
1140
1141        return req;
1142    }
1143
1144    private static Uri createDraftMmsMessage(PduPersister persister, SendReq sendReq,
1145            SlideshowModel slideshow) {
1146        try {
1147            PduBody pb = slideshow.toPduBody();
1148            sendReq.setBody(pb);
1149            Uri res = persister.persist(sendReq, Mms.Draft.CONTENT_URI);
1150            slideshow.sync(pb);
1151            return res;
1152        } catch (MmsException e) {
1153            return null;
1154        }
1155    }
1156
1157    private void asyncUpdateDraftMmsMessage(final Conversation conv) {
1158        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
1159            LogTag.debug("asyncUpdateDraftMmsMessage conv=%s mMessageUri=%s", conv, mMessageUri);
1160        }
1161
1162        final PduPersister persister = PduPersister.getPduPersister(mContext);
1163        final SendReq sendReq = makeSendReq(conv, mSubject);
1164
1165        new Thread(new Runnable() {
1166            public void run() {
1167                conv.ensureThreadId();
1168                conv.setDraftState(true);
1169                if (mMessageUri == null) {
1170                    mMessageUri = createDraftMmsMessage(persister, sendReq, mSlideshow);
1171                } else {
1172                    updateDraftMmsMessage(mMessageUri, persister, mSlideshow, sendReq);
1173                }
1174            }
1175        }).start();
1176
1177        // Be paranoid and delete any SMS drafts that might be lying around.
1178        asyncDeleteDraftSmsMessage(conv);
1179    }
1180
1181    private static void updateDraftMmsMessage(Uri uri, PduPersister persister,
1182            SlideshowModel slideshow, SendReq sendReq) {
1183        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
1184            LogTag.debug("updateDraftMmsMessage uri=%s", uri);
1185        }
1186        if (uri == null) {
1187            Log.e(TAG, "updateDraftMmsMessage null uri");
1188            return;
1189        }
1190        persister.updateHeaders(uri, sendReq);
1191        final PduBody pb = slideshow.toPduBody();
1192
1193        try {
1194            persister.updateParts(uri, pb);
1195        } catch (MmsException e) {
1196            Log.e(TAG, "updateDraftMmsMessage: cannot update message " + uri);
1197        }
1198
1199        slideshow.sync(pb);
1200    }
1201
1202    private static final String SMS_DRAFT_WHERE = Sms.TYPE + "=" + Sms.MESSAGE_TYPE_DRAFT;
1203    private static final String[] SMS_BODY_PROJECTION = { Sms.BODY };
1204    private static final int SMS_BODY_INDEX = 0;
1205
1206    /**
1207     * Reads a draft message for the given thread ID from the database,
1208     * if there is one, deletes it from the database, and returns it.
1209     * @return The draft message or an empty string.
1210     */
1211    private static String readDraftSmsMessage(Context context, long thread_id, Conversation conv) {
1212        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
1213            LogTag.debug("readDraftSmsMessage tid=%d", thread_id);
1214        }
1215        ContentResolver cr = context.getContentResolver();
1216
1217        // If it's an invalid thread, don't bother.
1218        if (thread_id <= 0) {
1219            return "";
1220        }
1221
1222        Uri thread_uri = ContentUris.withAppendedId(Sms.Conversations.CONTENT_URI, thread_id);
1223        String body = "";
1224
1225        Cursor c = SqliteWrapper.query(context, cr,
1226                        thread_uri, SMS_BODY_PROJECTION, SMS_DRAFT_WHERE, null, null);
1227        try {
1228            if (c.moveToFirst()) {
1229                body = c.getString(SMS_BODY_INDEX);
1230            }
1231        } finally {
1232            c.close();
1233        }
1234
1235        // Clean out drafts for this thread -- if the recipient set changes,
1236        // we will lose track of the original draft and be unable to delete
1237        // it later.  The message will be re-saved if necessary upon exit of
1238        // the activity.
1239        SqliteWrapper.delete(context, cr, thread_uri, SMS_DRAFT_WHERE, null);
1240
1241        // We found a draft, and if there are no messages in the conversation,
1242        // that means we deleted the thread, too. Must reset the thread id
1243        // so we'll eventually create a new thread.
1244        if (conv.getMessageCount() == 0) {
1245            if (DEBUG) LogTag.debug("readDraftSmsMessage calling clearThreadId");
1246            conv.clearThreadId();
1247
1248            // since we removed the draft message in the db, and the conversation no longer
1249            // has a thread id, let's clear the draft state for 'thread_id' in the draft cache.
1250            // Otherwise if a new message arrives it could be assigned the same thread id, and
1251            // we'd mistaken it for a draft due to the stale draft cache.
1252            DraftCache.getInstance().setDraftState(thread_id, false);
1253        }
1254
1255        return body;
1256    }
1257
1258    private void asyncUpdateDraftSmsMessage(final Conversation conv, final String contents) {
1259        new Thread(new Runnable() {
1260            public void run() {
1261                long threadId = conv.ensureThreadId();
1262                conv.setDraftState(true);
1263                updateDraftSmsMessage(threadId, contents);
1264            }
1265        }).start();
1266    }
1267
1268    private void updateDraftSmsMessage(long thread_id, String contents) {
1269        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
1270            LogTag.debug("updateDraftSmsMessage tid=%d, contents=\"%s\"", thread_id, contents);
1271        }
1272
1273        // If we don't have a valid thread, there's nothing to do.
1274        if (thread_id <= 0) {
1275            return;
1276        }
1277
1278        ContentValues values = new ContentValues(3);
1279        values.put(Sms.THREAD_ID, thread_id);
1280        values.put(Sms.BODY, contents);
1281        values.put(Sms.TYPE, Sms.MESSAGE_TYPE_DRAFT);
1282        SqliteWrapper.insert(mContext, mContentResolver, Sms.CONTENT_URI, values);
1283        asyncDeleteDraftMmsMessage(thread_id);
1284    }
1285
1286    private void asyncDelete(final Uri uri, final String selection, final String[] selectionArgs) {
1287        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
1288            LogTag.debug("asyncDelete %s where %s", uri, selection);
1289        }
1290        new Thread(new Runnable() {
1291            public void run() {
1292                SqliteWrapper.delete(mContext, mContentResolver, uri, selection, selectionArgs);
1293            }
1294        }).start();
1295    }
1296
1297    private void asyncDeleteDraftSmsMessage(Conversation conv) {
1298        long threadId = conv.getThreadId();
1299        if (threadId > 0) {
1300            asyncDelete(ContentUris.withAppendedId(Sms.Conversations.CONTENT_URI, threadId),
1301                SMS_DRAFT_WHERE, null);
1302        }
1303    }
1304
1305    private void deleteDraftSmsMessage(long threadId) {
1306        SqliteWrapper.delete(mContext, mContentResolver,
1307                ContentUris.withAppendedId(Sms.Conversations.CONTENT_URI, threadId),
1308                SMS_DRAFT_WHERE, null);
1309    }
1310
1311    private void asyncDeleteDraftMmsMessage(long threadId) {
1312        final String where = Mms.THREAD_ID + " = " + threadId;
1313        asyncDelete(Mms.Draft.CONTENT_URI, where, null);
1314    }
1315
1316}
1317