1/*
2 * Copyright (C) 2015 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 */
16package com.android.messaging.ui.conversation;
17
18import android.os.Parcel;
19import android.os.Parcelable;
20
21import com.android.messaging.ui.contact.ContactPickerFragment;
22import com.android.messaging.util.Assert;
23import com.google.common.annotations.VisibleForTesting;
24
25/**
26 * Keeps track of the different UI states that the ConversationActivity may be in. This acts as
27 * a state machine which, based on different actions (e.g. onAddMoreParticipants), notifies the
28 * ConversationActivity about any state UI change so it can update the visuals. This class
29 * implements Parcelable and it's persisted across activity tear down and relaunch.
30 */
31public class ConversationActivityUiState implements Parcelable, Cloneable {
32    interface ConversationActivityUiStateHost {
33        void onConversationContactPickerUiStateChanged(int oldState, int newState, boolean animate);
34    }
35
36    /*------ Overall UI states (conversation & contact picker) ------*/
37
38    /** Only a full screen conversation is showing. */
39    public static final int STATE_CONVERSATION_ONLY = 1;
40    /** Only a full screen contact picker is showing asking user to pick the initial contact. */
41    public static final int STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT = 2;
42    /**
43     * Only a full screen contact picker is showing asking user to pick more participants. This
44     * happens after the user picked the initial contact, and then decide to go back and add more.
45     */
46    public static final int STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS = 3;
47    /**
48     * Only a full screen contact picker is showing asking user to pick more participants. However
49     * user has reached max number of conversation participants and can add no more.
50     */
51    public static final int STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS = 4;
52    /**
53     * A hybrid mode where the conversation view + contact chips view are showing. This happens
54     * right after the user picked the initial contact for which a 1-1 conversation is fetched or
55     * created.
56     */
57    public static final int STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW = 5;
58
59    // The overall UI state of the ConversationActivity.
60    private int mConversationContactUiState;
61
62    // The currently displayed conversation (if any).
63    private String mConversationId;
64
65    // Indicates whether we should put focus in the compose message view when the
66    // ConversationFragment is attached. This is a transient state that's not persisted as
67    // part of the parcelable.
68    private boolean mPendingResumeComposeMessage = false;
69
70    // The owner ConversationActivity. This is not parceled since the instance always change upon
71    // object reuse.
72    private ConversationActivityUiStateHost mHost;
73
74    // Indicates the owning ConverastionActivity is in the process of updating its UI presentation
75    // to be in sync with the UI states. Outside of the UI updates, the UI states here should
76    // ALWAYS be consistent with the actual states of the activity.
77    private int mUiUpdateCount;
78
79    /**
80     * Create a new instance with an initial conversation id.
81     */
82    ConversationActivityUiState(final String conversationId) {
83        // The conversation activity may be initialized with only one of two states:
84        // Conversation-only (when there's a conversation id) or picking initial contact
85        // (when no conversation id is given).
86        mConversationId = conversationId;
87        mConversationContactUiState = conversationId == null ?
88                STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT : STATE_CONVERSATION_ONLY;
89    }
90
91    public void setHost(final ConversationActivityUiStateHost host) {
92        mHost = host;
93    }
94
95    public boolean shouldShowConversationFragment() {
96        return mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW ||
97                mConversationContactUiState == STATE_CONVERSATION_ONLY;
98    }
99
100    public boolean shouldShowContactPickerFragment() {
101        return mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS ||
102                mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS ||
103                mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT ||
104                mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW;
105    }
106
107    /**
108     * Returns whether there's a pending request to resume message compose (i.e. set focus to
109     * the compose message view and show the soft keyboard). If so, this request will be served
110     * when the conversation fragment get created and resumed. This happens when the user commits
111     * participant selection for a group conversation and goes back to the conversation fragment.
112     * Since conversation fragment creation happens asynchronously, we issue and track this
113     * pending request for it to be eventually fulfilled.
114     */
115    public boolean shouldResumeComposeMessage() {
116        if (mPendingResumeComposeMessage) {
117            // This is a one-shot operation that just keeps track of the pending resume compose
118            // state. This is also a non-critical operation so we don't care about failure case.
119            mPendingResumeComposeMessage = false;
120            return true;
121        }
122        return false;
123    }
124
125    public int getDesiredContactPickingMode() {
126        switch (mConversationContactUiState) {
127            case STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS:
128                return ContactPickerFragment.MODE_PICK_MORE_CONTACTS;
129            case STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS:
130                return ContactPickerFragment.MODE_PICK_MAX_PARTICIPANTS;
131            case STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT:
132                return ContactPickerFragment.MODE_PICK_INITIAL_CONTACT;
133            case STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW:
134                return ContactPickerFragment.MODE_CHIPS_ONLY;
135            default:
136                Assert.fail("Invalid contact picking mode for ConversationActivity!");
137                return ContactPickerFragment.MODE_UNDEFINED;
138        }
139    }
140
141    public String getConversationId() {
142        return mConversationId;
143    }
144
145    /**
146     * Called whenever the contact picker fragment successfully fetched or created a conversation.
147     */
148    public void onGetOrCreateConversation(final String conversationId) {
149        int newState = STATE_CONVERSATION_ONLY;
150        if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) {
151            newState = STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW;
152        } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS ||
153                mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS) {
154            newState = STATE_CONVERSATION_ONLY;
155        } else {
156            // New conversation should only be created when we are in one of the contact picking
157            // modes.
158            Assert.fail("Invalid conversation activity state: can't create conversation!");
159        }
160        mConversationId = conversationId;
161        performUiStateUpdate(newState, true);
162    }
163
164    /**
165     * Called when the user started composing message. If we are in the hybrid chips state, we
166     * should commit to enter the conversation only state.
167     */
168    public void onStartMessageCompose() {
169        // This cannot happen when we are in one of the full-screen contact picking states.
170        Assert.isTrue(mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT &&
171                mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS &&
172                mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS);
173        if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) {
174            performUiStateUpdate(STATE_CONVERSATION_ONLY, true);
175        }
176    }
177
178    /**
179     * Called when the user initiated an action to add more participants in the hybrid state,
180     * namely clicking on the "add more participants" button or entered a new contact chip via
181     * auto-complete.
182     */
183    public void onAddMoreParticipants() {
184        if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) {
185            mPendingResumeComposeMessage = true;
186            performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, true);
187        } else {
188            // This is only possible in the hybrid state.
189            Assert.fail("Invalid conversation activity state: can't add more participants!");
190        }
191    }
192
193    /**
194     * Called each time the number of participants is updated to check against the limit and
195     * update the ui state accordingly.
196     */
197    public void onParticipantCountUpdated(final boolean canAddMoreParticipants) {
198        if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS
199                && !canAddMoreParticipants) {
200            performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS, false);
201        } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS
202                && canAddMoreParticipants) {
203            performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, false);
204        }
205    }
206
207    private void performUiStateUpdate(final int conversationContactState, final boolean animate) {
208        // This starts one UI update cycle, during which we allow the conversation activity's
209        // UI presentation to be temporarily out of sync with the states here.
210        beginUiUpdate();
211
212        if (conversationContactState != mConversationContactUiState) {
213            final int oldState = mConversationContactUiState;
214            mConversationContactUiState = conversationContactState;
215            notifyOnOverallUiStateChanged(oldState, mConversationContactUiState, animate);
216        }
217        endUiUpdate();
218    }
219
220    private void notifyOnOverallUiStateChanged(
221            final int oldState, final int newState, final boolean animate) {
222        // Always verify state validity whenever we have a state change.
223        assertValidState();
224        Assert.isTrue(isUiUpdateInProgress());
225
226        // Only do this if we are still attached to the host. mHost can be null if the host
227        // activity is already destroyed, but due to timing the contained UI components may still
228        // receive events such as focus change and trigger a callback to the Ui state. We'd like
229        // to guard against those cases.
230        if (mHost != null) {
231            mHost.onConversationContactPickerUiStateChanged(oldState, newState, animate);
232        }
233    }
234
235    private void assertValidState() {
236        // Conversation id may be null IF AND ONLY IF the user is picking the initial contact to
237        // start a conversation.
238        Assert.isTrue((mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) ==
239                (mConversationId == null));
240    }
241
242    private void beginUiUpdate() {
243        mUiUpdateCount++;
244    }
245
246    private void endUiUpdate() {
247        if (--mUiUpdateCount < 0) {
248            Assert.fail("Unbalanced Ui updates!");
249        }
250    }
251
252    private boolean isUiUpdateInProgress() {
253        return mUiUpdateCount > 0;
254    }
255
256    @Override
257    public int describeContents() {
258        return 0;
259    }
260
261    @Override
262    public void writeToParcel(final Parcel dest, final int flags) {
263        dest.writeInt(mConversationContactUiState);
264        dest.writeString(mConversationId);
265    }
266
267    private ConversationActivityUiState(final Parcel in) {
268        mConversationContactUiState = in.readInt();
269        mConversationId = in.readString();
270
271        // Always verify state validity whenever we initialize states.
272        assertValidState();
273    }
274
275    public static final Parcelable.Creator<ConversationActivityUiState> CREATOR
276        = new Parcelable.Creator<ConversationActivityUiState>() {
277        @Override
278        public ConversationActivityUiState createFromParcel(final Parcel in) {
279            return new ConversationActivityUiState(in);
280        }
281
282        @Override
283        public ConversationActivityUiState[] newArray(final int size) {
284            return new ConversationActivityUiState[size];
285        }
286    };
287
288    @Override
289    protected ConversationActivityUiState clone() {
290        try {
291            return (ConversationActivityUiState) super.clone();
292        } catch (CloneNotSupportedException e) {
293            Assert.fail("ConversationActivityUiState: failed to clone(). Is there a mutable " +
294                    "reference?");
295        }
296        return null;
297    }
298
299    /**
300     * allows for overridding the internal UI state. Should never be called except by test code.
301     */
302    @VisibleForTesting
303    void testSetUiState(final int uiState) {
304        mConversationContactUiState = uiState;
305    }
306}
307