ConversationViewFragment.java revision c9d59184da271d5a6974edb709e3b39a5a970fa7
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 com.google.common.collect.Maps; 21 22import android.app.Activity; 23import android.app.Fragment; 24import android.app.LoaderManager; 25import android.content.ActivityNotFoundException; 26import android.content.Context; 27import android.content.CursorLoader; 28import android.content.Intent; 29import android.content.Loader; 30import android.database.Cursor; 31import android.database.CursorWrapper; 32import android.net.Uri; 33import android.os.Bundle; 34import android.os.Handler; 35import android.provider.Browser; 36import android.view.LayoutInflater; 37import android.view.Menu; 38import android.view.MenuInflater; 39import android.view.MenuItem; 40import android.view.MotionEvent; 41import android.view.View; 42import android.view.ViewGroup; 43import android.webkit.ConsoleMessage; 44import android.webkit.WebChromeClient; 45import android.webkit.WebSettings; 46import android.webkit.WebView; 47import android.webkit.WebViewClient; 48import android.widget.ResourceCursorAdapter; 49 50import com.android.mail.FormattedDateBuilder; 51import com.android.mail.R; 52import com.android.mail.browse.ConversationContainer; 53import com.android.mail.browse.ConversationViewHeader; 54import com.android.mail.browse.ConversationWebView; 55import com.android.mail.browse.MessageHeaderView; 56import com.android.mail.browse.MessageHeaderView.MessageHeaderViewCallbacks; 57import com.android.mail.providers.Account; 58import com.android.mail.providers.Conversation; 59import com.android.mail.providers.ListParams; 60import com.android.mail.providers.Message; 61import com.android.mail.providers.UIProvider; 62import com.android.mail.utils.LogUtils; 63import com.android.mail.utils.Utils; 64 65import java.util.Map; 66 67/** 68 * The conversation view UI component. 69 */ 70public final class ConversationViewFragment extends Fragment implements 71 LoaderManager.LoaderCallbacks<Cursor>, 72 ConversationViewHeader.ConversationViewHeaderCallbacks, 73 MessageHeaderViewCallbacks { 74 75 private static final String LOG_TAG = new LogUtils().getLogTag(); 76 77 private static final int MESSAGE_LOADER_ID = 0; 78 79 private ControllableActivity mActivity; 80 81 private Context mContext; 82 83 private Conversation mConversation; 84 85 private ConversationViewHeader mConversationHeader; 86 87 private ConversationContainer mConversationContainer; 88 89 private Account mAccount; 90 91 private ConversationWebView mWebView; 92 93 private HtmlConversationTemplates mTemplates; 94 95 private String mBaseUri; 96 97 private final Handler mHandler = new Handler(); 98 99 private final MailJsBridge mJsBridge = new MailJsBridge(); 100 101 private final WebViewClient mWebViewClient = new ConversationWebViewClient(); 102 103 private MessageListAdapter mAdapter; 104 105 private boolean mViewsCreated; 106 107 private MenuItem mChangeFoldersMenuItem; 108 109 private float mDensity; 110 111 private static final String ARG_ACCOUNT = "account"; 112 private static final String ARG_CONVERSATION = "conversation"; 113 114 /** 115 * Constructor needs to be public to handle orientation changes and activity lifecycle events. 116 */ 117 public ConversationViewFragment() { 118 super(); 119 } 120 121 /** 122 * Creates a new instance of {@link ConversationViewFragment}, initialized 123 * to display conversation. 124 */ 125 public static ConversationViewFragment newInstance(Account account, 126 Conversation conversation) { 127 ConversationViewFragment f = new ConversationViewFragment(); 128 Bundle args = new Bundle(); 129 args.putParcelable(ARG_ACCOUNT, account); 130 args.putParcelable(ARG_CONVERSATION, conversation); 131 f.setArguments(args); 132 return f; 133 } 134 135 @Override 136 public void onActivityCreated(Bundle savedInstanceState) { 137 super.onActivityCreated(savedInstanceState); 138 // Strictly speaking, we get back an android.app.Activity from getActivity. However, the 139 // only activity creating a ConversationListContext is a MailActivity which is of type 140 // ControllableActivity, so this cast should be safe. If this cast fails, some other 141 // activity is creating ConversationListFragments. This activity must be of type 142 // ControllableActivity. 143 final Activity activity = getActivity(); 144 if (! (activity instanceof ControllableActivity)) { 145 LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" 146 + "create it. Cannot proceed."); 147 } 148 mActivity = (ControllableActivity) activity; 149 mContext = mActivity.getApplicationContext(); 150 if (mActivity.isFinishing()) { 151 // Activity is finishing, just bail. 152 return; 153 } 154 mActivity.attachConversationView(this); 155 mTemplates = new HtmlConversationTemplates(mContext); 156 157 mAdapter = new MessageListAdapter(mActivity.getActivityContext(), 158 null /* cursor */, mAccount, getLoaderManager(), this); 159 mConversationContainer.setOverlayAdapter(mAdapter); 160 161 mDensity = getResources().getDisplayMetrics().density; 162 163 // Show conversation and start loading messages. 164 showConversation(); 165 } 166 167 @Override 168 public void onCreate(Bundle savedState) { 169 LogUtils.v(LOG_TAG, "onCreate in FolderListFragment(this=%s)", this); 170 super.onCreate(savedState); 171 172 Bundle args = getArguments(); 173 mAccount = args.getParcelable(ARG_ACCOUNT); 174 mConversation = args.getParcelable(ARG_CONVERSATION); 175 mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id; 176 177 // not really, we just want to get a crack to store a reference to the change_folders item 178 setHasOptionsMenu(true); 179 } 180 181 @Override 182 public View onCreateView(LayoutInflater inflater, 183 ViewGroup container, Bundle savedInstanceState) { 184 View rootView = inflater.inflate(R.layout.conversation_view, null); 185 mConversationContainer = (ConversationContainer) rootView 186 .findViewById(R.id.conversation_container); 187 mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview); 188 mConversationHeader = (ConversationViewHeader) mConversationContainer.findViewById( 189 R.id.conversation_header); 190 mConversationHeader.setCallbacks(this); 191 192 mWebView.addJavascriptInterface(mJsBridge, "mail"); 193 mWebView.setWebViewClient(mWebViewClient); 194 mWebView.setWebChromeClient(new WebChromeClient() { 195 @Override 196 public boolean onConsoleMessage(ConsoleMessage consoleMessage) { 197 LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(), 198 consoleMessage.sourceId(), consoleMessage.lineNumber()); 199 return true; 200 } 201 }); 202 203 final WebSettings settings = mWebView.getSettings(); 204 205 settings.setJavaScriptEnabled(true); 206 settings.setUseWideViewPort(true); 207 208 settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL); 209 210 settings.setSupportZoom(true); 211 settings.setBuiltInZoomControls(true); 212 settings.setDisplayZoomControls(false); 213 214 mViewsCreated = true; 215 216 return rootView; 217 } 218 219 @Override 220 public void onDestroyView() { 221 super.onDestroyView(); 222 mViewsCreated = false; 223 mActivity.attachConversationView(null); 224 } 225 226 @Override 227 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 228 super.onCreateOptionsMenu(menu, inflater); 229 230 mChangeFoldersMenuItem = menu.findItem(R.id.change_folders); 231 } 232 233 public void onPrepareOptionsMenu(Menu menu) { 234 super.onPrepareOptionsMenu(menu); 235 boolean showMarkImportant = !mConversation.isImportant(); 236 237 final MenuItem markImportant = menu.findItem(R.id.mark_important); 238 if (markImportant != null) { 239 markImportant.setVisible(showMarkImportant 240 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 241 final MenuItem markNotImportant = menu.findItem(R.id.mark_not_important); 242 markNotImportant.setVisible(!showMarkImportant 243 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 244 } 245 // TODO(mindyp) show/ hide spam and mute based on conversation properties to be added. 246 } 247 /** 248 * Handles a request to show a new conversation list, either from a search query or for viewing 249 * a folder. This will initiate a data load, and hence must be called on the UI thread. 250 */ 251 private void showConversation() { 252 // initialize conversation header, measure its height manually, and inform template render 253 // TODO: inform template render of initial header height 254 mConversationHeader.setSubject(mConversation.subject, false /* notify */); 255 if (mAccount.supportsCapability( 256 UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV)) { 257 mConversationHeader.setFolders(mConversation, false /* notify */); 258 } 259 260 getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, this); 261 } 262 263 @Override 264 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 265 return new MessageLoader(mContext, mConversation.messageListUri); 266 } 267 268 @Override 269 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 270 MessageCursor messageCursor = (MessageCursor) data; 271 272 if (mAdapter.getCursor() == null) { 273 renderConversation(messageCursor); 274 } else { 275 updateConversation(messageCursor); 276 } 277 } 278 279 @Override 280 public void onLoaderReset(Loader<Cursor> loader) { 281 mAdapter.swapCursor(null); 282 } 283 284 private void renderConversation(MessageCursor messageCursor) { 285 mWebView.loadDataWithBaseURL(mBaseUri, renderMessageBodies(messageCursor), "text/html", 286 "utf-8", null); 287 mAdapter.swapCursor(messageCursor); 288 } 289 290 private void updateConversation(MessageCursor messageCursor) { 291 // TODO: handle server-side conversation updates 292 // for simple things like header data changes, just re-render the affected headers 293 // if a new message is present, save off the pending cursor and show a notification to 294 // re-render 295 296 final MessageCursor oldCursor = (MessageCursor) mAdapter.getCursor(); 297 mAdapter.swapCursor(messageCursor); 298 } 299 300 private String renderMessageBodies(MessageCursor messageCursor) { 301 int pos = -1; 302 303 // N.B. the units of height for spacers are actually dp and not px because WebView assumes 304 // a pixel is an mdpi pixel, unless you set device-dpi. 305 306 final int headerHeightPx = Utils.measureViewHeight(mConversationHeader, 307 mConversationContainer); 308 mTemplates.startConversation((int) (headerHeightPx / mDensity)); 309 310 // FIXME: measure the header (and the attachments) and insert spacers of appropriate size 311 final int spacerH = (Utils.useTabletUI(mContext)) ? 112 : 96; 312 313 boolean allowNetworkImages = false; 314 315 while (messageCursor.moveToPosition(++pos)) { 316 final Message msg = messageCursor.get(); 317 // TODO: save/restore 'show pics' state 318 final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */; 319 allowNetworkImages |= safeForImages; 320 mTemplates.appendMessageHtml(msg, true /* expanded */, safeForImages, 1.0f, spacerH); 321 } 322 323 mWebView.getSettings().setBlockNetworkImage(!allowNetworkImages); 324 325 return mTemplates.endConversation(mBaseUri, 320); 326 } 327 328 public void onTouchEvent(MotionEvent event) { 329 // TODO: (mindyp) when there is an undo bar, check for event !in undo bar 330 // if its not in undo bar, dismiss the undo bar. 331 } 332 333 // BEGIN conversation header callbacks 334 @Override 335 public void onFoldersClicked() { 336 if (mChangeFoldersMenuItem == null) { 337 LogUtils.e(LOG_TAG, "unable to open 'change folders' dialog for a conversation"); 338 return; 339 } 340 mActivity.onOptionsItemSelected(mChangeFoldersMenuItem); 341 } 342 343 @Override 344 public void onConversationViewHeaderHeightChange(int newHeight) { 345 // TODO: propagate the new height to the header's HTML spacer. This can happen when labels 346 // are added/removed 347 } 348 349 @Override 350 public String getSubjectRemainder(String subject) { 351 // TODO: hook this up to action bar 352 return subject; 353 } 354 // END conversation header callbacks 355 356 // START message header callbacks 357 @Override 358 public void setMessageSpacerHeight(Message msg, int height) { 359 // TODO: update message HTML spacer height 360 // TODO: expand this concept to handle bottom-aligned attachments 361 } 362 363 @Override 364 public void setMessageExpanded(Message msg, boolean expanded, int spacerHeight) { 365 // TODO: show/hide the HTML message body and update the spacer height 366 } 367 368 @Override 369 public void showExternalResources(Message msg) { 370 mWebView.getSettings().setBlockNetworkImage(false); 371 mWebView.loadUrl("javascript:unblockImages('" + mTemplates.getMessageDomId(msg) + "');"); 372 } 373 // END message header callbacks 374 375 private static class MessageLoader extends CursorLoader { 376 private boolean mDeliveredFirstResults = false; 377 378 public MessageLoader(Context c, Uri uri) { 379 super(c, uri, UIProvider.MESSAGE_PROJECTION, null, null, null); 380 } 381 382 @Override 383 public Cursor loadInBackground() { 384 return new MessageCursor(super.loadInBackground()); 385 386 } 387 388 @Override 389 public void deliverResult(Cursor result) { 390 // We want to deliver these results, and then we want to make sure that any subsequent 391 // queries do not hit the network 392 super.deliverResult(result); 393 394 if (!mDeliveredFirstResults) { 395 mDeliveredFirstResults = true; 396 Uri uri = getUri(); 397 398 // Create a ListParams that tells the provider to not hit the network 399 final ListParams listParams = 400 new ListParams(ListParams.NO_LIMIT, false /* useNetwork */); 401 402 // Build the new uri with this additional parameter 403 uri = uri.buildUpon().appendQueryParameter( 404 UIProvider.LIST_PARAMS_QUERY_PARAMETER, listParams.serialize()).build(); 405 setUri(uri); 406 } 407 } 408 } 409 410 private static class MessageCursor extends CursorWrapper { 411 412 private Map<Long, Message> mCache = Maps.newHashMap(); 413 414 public MessageCursor(Cursor inner) { 415 super(inner); 416 } 417 418 public Message get() { 419 final long id = getWrappedCursor().getLong(UIProvider.MESSAGE_ID_COLUMN); 420 Message m = mCache.get(id); 421 if (m == null) { 422 m = new Message(this); 423 mCache.put(id, m); 424 } 425 return m; 426 } 427 } 428 429 private static class MessageListAdapter extends ResourceCursorAdapter { 430 431 private final FormattedDateBuilder mDateBuilder; 432 private final Account mAccount; 433 private final LoaderManager mLoaderManager; 434 private final MessageHeaderViewCallbacks mCallbacks; 435 436 public MessageListAdapter(Context context, Cursor messageCursor, Account account, 437 LoaderManager loaderManager, MessageHeaderViewCallbacks cb) { 438 super(context, R.layout.conversation_message_header, messageCursor, 0); 439 mDateBuilder = new FormattedDateBuilder(context); 440 mAccount = account; 441 mLoaderManager = loaderManager; 442 mCallbacks = cb; 443 } 444 445 @Override 446 public void bindView(View view, Context context, Cursor cursor) { 447 final Message msg = ((MessageCursor) cursor).get(); 448 MessageHeaderView header = (MessageHeaderView) view; 449 header.setCallbacks(mCallbacks); 450 header.initialize(mDateBuilder, mAccount, mLoaderManager, true /* expanded */, 451 msg.shouldShowImagePrompt(), false /* defaultReplyAll */); 452 header.bind(msg); 453 } 454 } 455 456 private static int[] parseInts(final String[] stringArray) { 457 final int len = stringArray.length; 458 final int[] ints = new int[len]; 459 for (int i = 0; i < len; i++) { 460 ints[i] = Integer.parseInt(stringArray[i]); 461 } 462 return ints; 463 } 464 465 private class ConversationWebViewClient extends WebViewClient { 466 467 @Override 468 public void onPageFinished(WebView view, String url) { 469 super.onPageFinished(view, url); 470 471 // TODO: save off individual message unread state (here, or in onLoadFinished?) so 472 // 'mark unread' restores the original unread state for each individual message 473 474 // mark as read upon open 475 if (!mConversation.read) { 476 mConversation.markRead(mContext, true /* read */); 477 mConversation.read = true; 478 } 479 } 480 481 @Override 482 public boolean shouldOverrideUrlLoading(WebView view, String url) { 483 boolean result = false; 484 final Uri uri = Uri.parse(url); 485 Intent intent = new Intent(Intent.ACTION_VIEW, uri); 486 intent.putExtra(Browser.EXTRA_APPLICATION_ID, getActivity().getPackageName()); 487 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 488 489 // FIXME: give provider a chance to customize url intents? 490 // Utils.addGoogleUriAccountIntentExtras(mContext, uri, mAccount, intent); 491 492 try { 493 mActivity.getActivityContext().startActivity(intent); 494 result = true; 495 } catch (ActivityNotFoundException ex) { 496 // If no application can handle the URL, assume that the 497 // caller can handle it. 498 } 499 500 return result; 501 } 502 503 } 504 505 /** 506 * NOTE: all public methods must be listed in the proguard flags so that they can be accessed 507 * via reflection and not stripped. 508 * 509 */ 510 private class MailJsBridge { 511 512 @SuppressWarnings("unused") 513 public void onWebContentGeometryChange(final String[] headerBottomStrs, 514 final String[] headerHeightStrs) { 515 mHandler.post(new Runnable() { 516 @Override 517 public void run() { 518 if (!mViewsCreated) { 519 LogUtils.d(LOG_TAG, "ignoring webContentGeometryChange because views" + 520 " are gone, %s", ConversationViewFragment.this); 521 return; 522 } 523 524 mConversationContainer.onGeometryChange(parseInts(headerBottomStrs), 525 parseInts(headerHeightStrs)); 526 } 527 }); 528 } 529 530 } 531 532} 533