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 { 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