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