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  */
16 package com.android.messaging.ui.conversation;
17 
18 import android.os.Parcel;
19 import android.os.Parcelable;
20 
21 import com.android.messaging.ui.contact.ContactPickerFragment;
22 import com.android.messaging.util.Assert;
23 import 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  */
31 public class ConversationActivityUiState implements Parcelable, Cloneable {
32     interface ConversationActivityUiStateHost {
onConversationContactPickerUiStateChanged(int oldState, int newState, boolean animate)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      */
ConversationActivityUiState(final String conversationId)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 
setHost(final ConversationActivityUiStateHost host)91     public void setHost(final ConversationActivityUiStateHost host) {
92         mHost = host;
93     }
94 
shouldShowConversationFragment()95     public boolean shouldShowConversationFragment() {
96         return mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW ||
97                 mConversationContactUiState == STATE_CONVERSATION_ONLY;
98     }
99 
shouldShowContactPickerFragment()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      */
shouldResumeComposeMessage()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 
getDesiredContactPickingMode()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 
getConversationId()141     public String getConversationId() {
142         return mConversationId;
143     }
144 
145     /**
146      * Called whenever the contact picker fragment successfully fetched or created a conversation.
147      */
onGetOrCreateConversation(final String conversationId)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      */
onStartMessageCompose()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      */
onAddMoreParticipants()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      */
onParticipantCountUpdated(final boolean canAddMoreParticipants)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 
performUiStateUpdate(final int conversationContactState, final boolean animate)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 
notifyOnOverallUiStateChanged( final int oldState, final int newState, final boolean animate)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 
assertValidState()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 
beginUiUpdate()242     private void beginUiUpdate() {
243         mUiUpdateCount++;
244     }
245 
endUiUpdate()246     private void endUiUpdate() {
247         if (--mUiUpdateCount < 0) {
248             Assert.fail("Unbalanced Ui updates!");
249         }
250     }
251 
isUiUpdateInProgress()252     private boolean isUiUpdateInProgress() {
253         return mUiUpdateCount > 0;
254     }
255 
256     @Override
describeContents()257     public int describeContents() {
258         return 0;
259     }
260 
261     @Override
writeToParcel(final Parcel dest, final int flags)262     public void writeToParcel(final Parcel dest, final int flags) {
263         dest.writeInt(mConversationContactUiState);
264         dest.writeString(mConversationId);
265     }
266 
ConversationActivityUiState(final Parcel in)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
clone()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
testSetUiState(final int uiState)303     void testSetUiState(final int uiState) {
304         mConversationContactUiState = uiState;
305     }
306 }
307