AbstractConversationViewFragment.java revision f4fce1227d8b49f45e6569f1590565f2df9e8d6e
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.ui; 19 20import android.app.Activity; 21import android.app.Fragment; 22import android.app.LoaderManager; 23import android.content.Context; 24import android.content.CursorLoader; 25import android.content.Loader; 26import android.database.Cursor; 27import android.database.DataSetObservable; 28import android.database.DataSetObserver; 29import android.net.Uri; 30import android.os.Bundle; 31import android.view.Menu; 32import android.view.MenuInflater; 33import android.view.MenuItem; 34 35import com.android.mail.ContactInfo; 36import com.android.mail.ContactInfoSource; 37import com.android.mail.FormattedDateBuilder; 38import com.android.mail.R; 39import com.android.mail.SenderInfoLoader; 40import com.android.mail.browse.MessageCursor; 41import com.android.mail.browse.ConversationViewAdapter.ConversationAccountController; 42import com.android.mail.browse.ConversationViewHeader.ConversationViewHeaderCallbacks; 43import com.android.mail.browse.MessageCursor.ConversationController; 44import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks; 45import com.android.mail.providers.Account; 46import com.android.mail.providers.AccountObserver; 47import com.android.mail.providers.Address; 48import com.android.mail.providers.Conversation; 49import com.android.mail.providers.Folder; 50import com.android.mail.providers.ListParams; 51import com.android.mail.providers.UIProvider; 52import com.android.mail.providers.UIProvider.AccountCapabilities; 53import com.android.mail.providers.UIProvider.FolderCapabilities; 54import com.android.mail.utils.LogTag; 55import com.android.mail.utils.LogUtils; 56import com.android.mail.utils.Utils; 57import com.google.common.collect.ImmutableMap; 58import com.google.common.collect.Maps; 59 60import java.util.Map; 61import java.util.Set; 62 63public abstract class AbstractConversationViewFragment extends Fragment implements 64 ConversationController, ConversationAccountController, MessageHeaderViewCallbacks, 65 ConversationViewHeaderCallbacks { 66 67 private static final String ARG_ACCOUNT = "account"; 68 public static final String ARG_CONVERSATION = "conversation"; 69 private static final String ARG_FOLDER = "folder"; 70 private static final String LOG_TAG = LogTag.getLogTag(); 71 protected static final int MESSAGE_LOADER = 0; 72 protected static final int CONTACT_LOADER = 1; 73 protected ControllableActivity mActivity; 74 private final MessageLoaderCallbacks mMessageLoaderCallbacks = new MessageLoaderCallbacks(); 75 protected FormattedDateBuilder mDateBuilder; 76 private final ContactLoaderCallbacks mContactLoaderCallbacks = new ContactLoaderCallbacks(); 77 private MenuItem mChangeFoldersMenuItem; 78 protected Conversation mConversation; 79 protected Folder mFolder; 80 protected String mBaseUri; 81 protected Account mAccount; 82 protected final Map<String, Address> mAddressCache = Maps.newHashMap(); 83 protected boolean mEnableContentReadySignal; 84 private MessageCursor mCursor; 85 private Context mContext; 86 public boolean mUserVisible; 87 private final AccountObserver mAccountObserver = new AccountObserver() { 88 @Override 89 public void onChanged(Account newAccount) { 90 mAccount = newAccount; 91 onAccountChanged(); 92 } 93 }; 94 95 public static Bundle makeBasicArgs(Account account, Folder folder) { 96 Bundle args = new Bundle(); 97 args.putParcelable(ARG_ACCOUNT, account); 98 args.putParcelable(ARG_FOLDER, folder); 99 return args; 100 } 101 102 /** 103 * Constructor needs to be public to handle orientation changes and activity 104 * lifecycle events. 105 */ 106 public AbstractConversationViewFragment() { 107 super(); 108 } 109 110 /** 111 * Subclasses must override, since this depends on how many messages are 112 * shown in the conversation view. 113 */ 114 protected abstract void markUnread(); 115 116 /** 117 * Subclasses must override this, since they may want to display a single or 118 * many messages related to this conversation. 119 */ 120 protected abstract void onMessageCursorLoadFinished(Loader<Cursor> loader, Cursor data, 121 boolean wasNull, boolean messageCursorChanged); 122 123 /** 124 * Subclasses must override this, since they may want to display a single or 125 * many messages related to this conversation. 126 */ 127 @Override 128 public abstract void onConversationViewHeaderHeightChange(int newHeight); 129 130 public abstract void onUserVisibleHintChanged(); 131 132 /** 133 * Subclasses must override this. 134 */ 135 protected abstract void onAccountChanged(); 136 137 @Override 138 public void onCreate(Bundle savedState) { 139 super.onCreate(savedState); 140 141 final Bundle args = getArguments(); 142 mAccount = args.getParcelable(ARG_ACCOUNT); 143 mConversation = args.getParcelable(ARG_CONVERSATION); 144 mFolder = args.getParcelable(ARG_FOLDER); 145 // On JB or newer, we use the 'webkitAnimationStart' DOM event to signal load complete 146 // Below JB, try to speed up initial render by having the webview do supplemental draws to 147 // custom a software canvas. 148 // TODO(mindyp): 149 //PAGE READINESS SIGNAL FOR JELLYBEAN AND NEWER 150 // Notify the app on 'webkitAnimationStart' of a simple dummy element with a simple no-op 151 // animation that immediately runs on page load. The app uses this as a signal that the 152 // content is loaded and ready to draw, since WebView delays firing this event until the 153 // layers are composited and everything is ready to draw. 154 // This signal does not seem to be reliable, so just use the old method for now. 155 mEnableContentReadySignal = false; //Utils.isRunningJellybeanOrLater(); 156 LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this); 157 // Not really, we just want to get a crack to store a reference to the change_folder item 158 setHasOptionsMenu(true); 159 } 160 161 @Override 162 public void onActivityCreated(Bundle savedInstanceState) { 163 super.onActivityCreated(savedInstanceState); 164 Activity activity = getActivity(); 165 if (!(activity instanceof ControllableActivity)) { 166 LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" 167 + "create it. Cannot proceed."); 168 } 169 if (activity.isFinishing()) { 170 // Activity is finishing, just bail. 171 return; 172 } 173 mActivity = (ControllableActivity) getActivity(); 174 mContext = activity.getApplicationContext(); 175 mDateBuilder = new FormattedDateBuilder((Context) mActivity); 176 mAccount = mAccountObserver.initialize(mActivity.getAccountController()); 177 } 178 179 @Override 180 public ConversationUpdater getListController() { 181 final ControllableActivity activity = (ControllableActivity) getActivity(); 182 return activity != null ? activity.getConversationUpdater() : null; 183 } 184 185 public Context getContext() { 186 return mContext; 187 } 188 189 public Conversation getConversation() { 190 return mConversation; 191 } 192 193 @Override 194 public MessageCursor getMessageCursor() { 195 return mCursor; 196 } 197 198 public MessageLoaderCallbacks getMessageLoaderCallbacks() { 199 return mMessageLoaderCallbacks; 200 } 201 202 public ContactLoaderCallbacks getContactInfoSource() { 203 return mContactLoaderCallbacks; 204 } 205 206 @Override 207 public Account getAccount() { 208 return mAccount; 209 } 210 211 @Override 212 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 213 super.onCreateOptionsMenu(menu, inflater); 214 mChangeFoldersMenuItem = menu.findItem(R.id.change_folder); 215 } 216 217 @Override 218 public void onPrepareOptionsMenu(Menu menu) { 219 super.onPrepareOptionsMenu(menu); 220 final boolean showMarkImportant = !mConversation.isImportant(); 221 Utils.setMenuItemVisibility(menu, R.id.mark_important, showMarkImportant 222 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 223 Utils.setMenuItemVisibility(menu, R.id.mark_not_important, !showMarkImportant 224 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 225 final boolean showDelete = mFolder != null && 226 mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE); 227 Utils.setMenuItemVisibility(menu, R.id.delete, showDelete); 228 // We only want to show the discard drafts menu item if we are not showing the delete menu 229 // item, and the current folder is a draft folder and the account supports discarding 230 // drafts for a conversation 231 final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() && 232 mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS); 233 Utils.setMenuItemVisibility(menu, R.id.discard_drafts, showDiscardDrafts); 234 final boolean archiveVisible = mAccount.supportsCapability(AccountCapabilities.ARCHIVE) 235 && mFolder != null && mFolder.supportsCapability(FolderCapabilities.ARCHIVE) 236 && !mFolder.isTrash(); 237 Utils.setMenuItemVisibility(menu, R.id.archive, archiveVisible); 238 Utils.setMenuItemVisibility(menu, R.id.remove_folder, !archiveVisible && mFolder != null 239 && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 240 && !mFolder.isProviderFolder()); 241 final MenuItem removeFolder = menu.findItem(R.id.remove_folder); 242 if (removeFolder != null) { 243 removeFolder.setTitle(getString(R.string.remove_folder, mFolder.name)); 244 } 245 Utils.setMenuItemVisibility(menu, R.id.report_spam, 246 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 247 && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM) 248 && !mConversation.spam); 249 Utils.setMenuItemVisibility(menu, R.id.mark_not_spam, 250 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 251 && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM) 252 && mConversation.spam); 253 Utils.setMenuItemVisibility(menu, R.id.report_phishing, 254 mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null 255 && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING) 256 && !mConversation.phishing); 257 Utils.setMenuItemVisibility(menu, R.id.mute, 258 mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null 259 && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE) 260 && !mConversation.muted); 261 } 262 263 @Override 264 public boolean onOptionsItemSelected(MenuItem item) { 265 boolean handled = false; 266 switch (item.getItemId()) { 267 case R.id.inside_conversation_unread: 268 markUnread(); 269 handled = true; 270 break; 271 } 272 return handled; 273 } 274 275 // BEGIN conversation header callbacks 276 @Override 277 public void onFoldersClicked() { 278 if (mChangeFoldersMenuItem == null) { 279 LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation"); 280 return; 281 } 282 mActivity.onOptionsItemSelected(mChangeFoldersMenuItem); 283 } 284 285 @Override 286 public String getSubjectRemainder(String subject) { 287 final SubjectDisplayChanger sdc = mActivity.getSubjectDisplayChanger(); 288 if (sdc == null) { 289 return subject; 290 } 291 return sdc.getUnshownSubject(subject); 292 } 293 // END conversation header callbacks 294 295 @Override 296 public void onDestroyView() { 297 super.onDestroyView(); 298 mAccountObserver.unregisterAndDestroy(); 299 } 300 301 /** 302 * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for 303 * reliability on older platforms. 304 */ 305 public void setExtraUserVisibleHint(boolean isVisibleToUser) { 306 LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this); 307 if (mUserVisible != isVisibleToUser) { 308 mUserVisible = isVisibleToUser; 309 onUserVisibleHintChanged(); 310 } 311 } 312 313 private class MessageLoaderCallbacks implements LoaderManager.LoaderCallbacks<Cursor> { 314 315 @Override 316 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 317 return new MessageLoader(mActivity.getActivityContext(), mConversation, 318 AbstractConversationViewFragment.this); 319 } 320 321 @Override 322 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 323 // ignore truly duplicate results 324 // this can happen when restoring after rotation 325 if (mCursor == data) { 326 return; 327 } else { 328 MessageCursor messageCursor = (MessageCursor) data; 329 330 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 331 LogUtils.d(LOG_TAG, "LOADED CONVERSATION= %s", messageCursor.getDebugDump()); 332 } 333 334 // TODO: handle ERROR status 335 336 // When the last cursor had message(s), and the new version has 337 // no messages, we need to exit conversation view. 338 if (messageCursor.getCount() == 0 && mCursor != null) { 339 if (mUserVisible) { 340 // need to exit this view- conversation may have been 341 // deleted, or for whatever reason is now invalid (e.g. 342 // discard single draft) 343 // 344 // N.B. this may involve a fragment transaction, which 345 // FragmentManager will refuse to execute directly 346 // within onLoadFinished. Make sure the controller knows. 347 LogUtils.i(LOG_TAG, "CVF: visible conv has no messages, exiting conv mode"); 348 mActivity.getListHandler() 349 .onConversationSelected(null, true /* inLoaderCallbacks */); 350 } else { 351 // we expect that the pager adapter will remove this 352 // conversation fragment on its own due to a separate 353 // conversation cursor update (we might get here if the 354 // message list update fires first. nothing to do 355 // because we expect to be torn down soon.) 356 LogUtils.i(LOG_TAG, "CVF: offscreen conv has no messages, ignoring update" 357 + " in anticipation of conv cursor update. c=%s", mConversation.uri); 358 } 359 360 return; 361 } 362 363 // ignore cursors that are still loading results 364 if (!messageCursor.isLoaded()) { 365 return; 366 } 367 boolean wasNull = mCursor == null; 368 boolean messageCursorChanged = mCursor != null 369 && messageCursor.hashCode() != mCursor.hashCode(); 370 mCursor = (MessageCursor) data; 371 onMessageCursorLoadFinished(loader, data, wasNull, messageCursorChanged); 372 } 373 } 374 375 @Override 376 public void onLoaderReset(Loader<Cursor> loader) { 377 mCursor = null; 378 } 379 380 } 381 382 private static class MessageLoader extends CursorLoader { 383 private boolean mDeliveredFirstResults = false; 384 private final Conversation mConversation; 385 private final ConversationController mController; 386 387 public MessageLoader(Context c, Conversation conv, ConversationController controller) { 388 super(c, conv.messageListUri, UIProvider.MESSAGE_PROJECTION, null, null, null); 389 mConversation = conv; 390 mController = controller; 391 } 392 393 @Override 394 public Cursor loadInBackground() { 395 return new MessageCursor(super.loadInBackground(), mConversation, mController); 396 } 397 398 @Override 399 public void deliverResult(Cursor result) { 400 // We want to deliver these results, and then we want to make sure 401 // that any subsequent 402 // queries do not hit the network 403 super.deliverResult(result); 404 405 if (!mDeliveredFirstResults) { 406 mDeliveredFirstResults = true; 407 Uri uri = getUri(); 408 409 // Create a ListParams that tells the provider to not hit the 410 // network 411 final ListParams listParams = new ListParams(ListParams.NO_LIMIT, 412 false /* useNetwork */); 413 414 // Build the new uri with this additional parameter 415 uri = uri 416 .buildUpon() 417 .appendQueryParameter(UIProvider.LIST_PARAMS_QUERY_PARAMETER, 418 listParams.serialize()).build(); 419 setUri(uri); 420 } 421 } 422 } 423 424 /** 425 * Inner class to to asynchronously load contact data for all senders in the conversation, 426 * and notify observers when the data is ready. 427 * 428 */ 429 protected class ContactLoaderCallbacks implements ContactInfoSource, 430 LoaderManager.LoaderCallbacks<ImmutableMap<String, ContactInfo>> { 431 432 private Set<String> mSenders; 433 private ImmutableMap<String, ContactInfo> mContactInfoMap; 434 private DataSetObservable mObservable = new DataSetObservable(); 435 436 public void setSenders(Set<String> emailAddresses) { 437 mSenders = emailAddresses; 438 } 439 440 @Override 441 public Loader<ImmutableMap<String, ContactInfo>> onCreateLoader(int id, Bundle args) { 442 return new SenderInfoLoader(mActivity.getActivityContext(), mSenders); 443 } 444 445 @Override 446 public void onLoadFinished(Loader<ImmutableMap<String, ContactInfo>> loader, 447 ImmutableMap<String, ContactInfo> data) { 448 mContactInfoMap = data; 449 mObservable.notifyChanged(); 450 } 451 452 @Override 453 public void onLoaderReset(Loader<ImmutableMap<String, ContactInfo>> loader) { 454 } 455 456 @Override 457 public ContactInfo getContactInfo(String email) { 458 if (mContactInfoMap == null) { 459 return null; 460 } 461 return mContactInfoMap.get(email); 462 } 463 464 @Override 465 public void registerObserver(DataSetObserver observer) { 466 mObservable.registerObserver(observer); 467 } 468 469 @Override 470 public void unregisterObserver(DataSetObserver observer) { 471 mObservable.unregisterObserver(observer); 472 } 473 474 } 475} 476